feat(search): Implement empty state and search settings UI

This commit is contained in:
SuperDragonXD
2025-07-26 18:09:40 +08:00
parent cacabee4e0
commit ccafdec7cc
14 changed files with 309 additions and 11 deletions

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<app.lawnchair.allapps.views.SearchResultEmptyState xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/search_result_empty_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:padding="32dp">
<ImageView
android:id="@+id/empty_state_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@null"
android:src="@drawable/ic_qsb_search"
android:layout_marginBottom="16dp"
app:tint="?android:textColorSecondary" />
<TextView
android:id="@+id/empty_state_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textColor="?android:attr/textColorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/empty_state_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary" />
</app.lawnchair.allapps.views.SearchResultEmptyState>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<app.lawnchair.allapps.views.SearchResultSearchSettings xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/search_result_search_settings"
android:layout_width="match_parent"
android:layout_height="@dimen/search_result_text_height"
android:orientation="horizontal"
android:gravity="end">
<ImageButton
android:background="@drawable/pill_ripple"
android:id="@+id/search_settings"
android:layout_gravity="bottom|center|end"
android:layout_width="@dimen/search_box_height"
android:textAllCaps="false"
android:clickable="true"
android:layout_height="@dimen/search_box_height"
android:src="@drawable/ic_setting"
android:textStyle="normal"
android:contentDescription="@string/settings" />
</app.lawnchair.allapps.views.SearchResultSearchSettings>

View File

@@ -725,7 +725,7 @@
-->
<!-- Launcher strings used -->
<string name="all_apps_device_search_hint">Search</string>
<string name="all_apps_device_search_hint">Search apps, web, and more</string>
<string name="all_apps_search_bar_hint">Search apps</string>
<string name="all_apps_no_search_results">No apps found matching \"<xliff:g example="Android" id="query">%1$s</xliff:g>\"</string>
@@ -808,4 +808,8 @@
<string name="warn_files_permission_content">To search your files, grant storage permissions to Lawnchair</string>
<string name="grant_requested_permissions">Grant permissions</string>
<string name="custom_search_label">Custom search engine name</string>
<string name="search_empty_state_title">Start searching</string>
<string name="search_empty_state_no_history_subtitle">Find apps, contacts, and more. Your recent searches will appear here.</string>
<string name="search_empty_state_history_disabled_subtitle">Find apps, contacts, and more.</string>
</resources>

View File

@@ -138,6 +138,10 @@ class AllAppsSearchInput(context: Context, attrs: AttributeSet?) :
with(input) {
addTextChangedListener {
if (input.text.toString().isEmpty()) {
searchAlgorithm?.doZeroStateSearch(this@AllAppsSearchInput)
}
actionButton.isVisible = !it.isNullOrEmpty()
micIcon.isVisible = shouldShowIcons && voiceIntent != null && it.isNullOrEmpty()
lensIcon.isVisible = shouldShowIcons && supportsLens && lensIntent != null && it.isNullOrEmpty()

View File

@@ -0,0 +1,48 @@
package app.lawnchair.allapps.views
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.ViewCompat
import app.lawnchair.search.adapter.SearchTargetCompat
import app.lawnchair.search.model.SearchResultActionCallBack
import app.lawnchair.theme.color.tokens.ColorTokens
import com.android.launcher3.R
import com.android.launcher3.util.Themes
class SearchResultEmptyState(context: Context, attrs: AttributeSet?) :
LinearLayout(context, attrs),
SearchResultView {
private lateinit var icon: ImageView
private lateinit var title: TextView
private lateinit var subtitle: TextView
override fun onFinishInflate() {
super.onFinishInflate()
title = ViewCompat.requireViewById(this, R.id.empty_state_title)
subtitle = ViewCompat.requireViewById(this, R.id.empty_state_subtitle)
icon = ViewCompat.requireViewById(this, R.id.empty_state_icon)
subtitle.setTextColor(Themes.getAttrColor(context, android.R.attr.textColorTertiary))
icon.setColorFilter(ColorTokens.ColorAccent.resolveColor(context))
}
override val isQuickLaunch: Boolean = false
override fun launch(): Boolean = false
override fun bind(
target: SearchTargetCompat,
shortcuts: List<SearchTargetCompat>,
callBack: SearchResultActionCallBack?,
) {
val extras = target.extras
val titleRes = extras.getInt("titleRes")
val subtitleRes = extras.getInt("subtitleRes")
if (titleRes != 0) title.setText(titleRes)
if (subtitleRes != 0) subtitle.setText(subtitleRes)
}
}

View File

@@ -0,0 +1,39 @@
package app.lawnchair.allapps.views
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.core.view.ViewCompat
import app.lawnchair.search.adapter.SearchTargetCompat
import app.lawnchair.search.model.SearchResultActionCallBack
import app.lawnchair.ui.preferences.PreferenceActivity
import app.lawnchair.ui.preferences.destinations.SearchRoute
import app.lawnchair.ui.preferences.navigation.Search
import com.android.launcher3.R
class SearchResultSearchSettings(context: Context, attrs: AttributeSet?) :
LinearLayout(context, attrs),
SearchResultView {
private lateinit var iconButton: ImageButton
override fun onFinishInflate() {
super.onFinishInflate()
iconButton = ViewCompat.requireViewById(this, R.id.search_settings)
iconButton.setOnClickListener {
context.startActivity(PreferenceActivity.createIntent(context, Search(SearchRoute.DRAWER_SEARCH)))
}
}
override val isQuickLaunch: Boolean = false
override fun launch(): Boolean = false
override fun bind(
target: SearchTargetCompat,
shortcuts: List<SearchTargetCompat>,
callBack: SearchResultActionCallBack?,
) {
// no-op
}
}

View File

@@ -36,6 +36,8 @@ class LawnchairSearchAdapterProvider(
append(SEARCH_RESULT_SETTINGS_TILE, R.layout.search_result_small_icon_row)
append(SEARCH_RESULT_RECENT_TILE, R.layout.search_result_small_icon_row)
append(SEARCH_RESULT_CALCULATOR, R.layout.search_result_tall_icon_row_calculator)
append(SEARCH_RESULT_EMPTY_STATE, R.layout.search_result_empty_state)
append(SEARCH_RESULT_SEARCH_SETTINGS, R.layout.search_result_search_settings)
}
private var quickLaunchItem: SearchResultView? = null
set(value) {
@@ -108,6 +110,8 @@ class LawnchairSearchAdapterProvider(
private const val SEARCH_RESULT_SETTINGS_TILE = 1 shl 18
private const val SEARCH_RESULT_RECENT_TILE = 1 shl 19
private const val SEARCH_RESULT_CALCULATOR = 1 shl 20
private const val SEARCH_RESULT_EMPTY_STATE = 1 shl 21
private const val SEARCH_RESULT_SEARCH_SETTINGS = 1 shl 22
val viewTypeMap = mapOf(
LayoutType.ICON_SINGLE_VERTICAL_TEXT to SEARCH_RESULT_ICON,
@@ -121,6 +125,8 @@ class LawnchairSearchAdapterProvider(
LayoutType.ICON_SLICE to SEARCH_RESULT_SETTINGS_TILE,
LayoutType.WIDGET_LIVE to SEARCH_RESULT_RECENT_TILE,
LayoutType.CALCULATOR to SEARCH_RESULT_CALCULATOR,
LayoutType.EMPTY_STATE to SEARCH_RESULT_EMPTY_STATE,
LayoutType.SEARCH_SETTINGS to SEARCH_RESULT_SEARCH_SETTINGS,
)
}
}

View File

@@ -275,6 +275,8 @@ data class SearchTargetCompat(
RESULT_TYPE_FILE_TILE,
RESULT_TYPE_SETTING_TILE,
RESULT_TYPE_CALCULATOR,
RESULT_TYPE_EMPTY_RESULT,
RESULT_TYPE_SEARCH_SETTINGS,
],
)
@Retention(AnnotationRetention.SOURCE)
@@ -292,6 +294,8 @@ data class SearchTargetCompat(
const val RESULT_TYPE_FILE_TILE = 1 shl 8
const val RESULT_TYPE_SETTING_TILE = 1 shl 9
const val RESULT_TYPE_CALCULATOR = 1 shl 10
const val RESULT_TYPE_EMPTY_RESULT = 1 shl 11
const val RESULT_TYPE_SEARCH_SETTINGS = 1 shl 12
fun wrap(target: SearchTarget): SearchTargetCompat = SearchTargetCompat(target)
}

View File

@@ -13,6 +13,7 @@ import android.provider.ContactsContract
import android.provider.MediaStore
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import app.lawnchair.allapps.views.SearchResultView
@@ -313,6 +314,53 @@ class SearchTargetFactory(
)
}
fun createEmptyStateTarget(
@StringRes titleRes: Int,
@StringRes subtitleRes: Int,
): SearchTargetCompat {
val id = "empty_state:$titleRes"
// The action doesn't do anything, it's just for display.
val action = SearchActionCompat.Builder(id, "")
.setIntent(Intent())
.build()
return SearchTargetCompat.Builder(
SearchTargetCompat.RESULT_TYPE_EMPTY_RESULT,
LayoutType.EMPTY_STATE,
id,
).apply {
setPackageName(EMPTY_STATE)
setUserHandle(Process.myUserHandle())
setSearchAction(action)
setExtras(
bundleOf(
"titleRes" to titleRes,
"subtitleRes" to subtitleRes,
),
)
}.build()
}
fun createSearchSettingsTarget(): SearchTargetCompat {
val id = "action:search_settings"
// The action doesn't do anything, it's just for display.
val action = SearchActionCompat.Builder(id, "")
.setIntent(Intent())
.build()
return SearchTargetCompat.Builder(
SearchTargetCompat.RESULT_TYPE_SEARCH_SETTINGS,
LayoutType.SEARCH_SETTINGS,
id,
).apply {
setPackageName(SEARCH_SETTINGS)
setUserHandle(Process.myUserHandle())
setSearchAction(action)
setExtras(Bundle())
}.build()
}
companion object {
private const val HASH_ALGORITHM = "SHA-256"
@@ -423,3 +471,5 @@ const val SHORTCUT = "shortcut"
const val HISTORY = "recent_keyword"
const val HEADER_JUSTIFY = "header_justify"
const val CALCULATOR = "calculator"
const val EMPTY_STATE = "empty_state"
const val SEARCH_SETTINGS = "search_settings"

View File

@@ -10,10 +10,12 @@ import app.lawnchair.search.algorithms.engine.ActionsSectionBuilder
import app.lawnchair.search.algorithms.engine.AppsAndShortcutsSectionBuilder
import app.lawnchair.search.algorithms.engine.CalculationSectionBuilder
import app.lawnchair.search.algorithms.engine.ContactsSectionBuilder
import app.lawnchair.search.algorithms.engine.EmptyStateSectionBuilder
import app.lawnchair.search.algorithms.engine.FilesSectionBuilder
import app.lawnchair.search.algorithms.engine.HistorySectionBuilder
import app.lawnchair.search.algorithms.engine.SearchProvider
import app.lawnchair.search.algorithms.engine.SearchResult
import app.lawnchair.search.algorithms.engine.SearchSettingsSectionBuilder
import app.lawnchair.search.algorithms.engine.SectionBuilder
import app.lawnchair.search.algorithms.engine.SettingsSectionBuilder
import app.lawnchair.search.algorithms.engine.WebSuggestionsSectionBuilder
@@ -26,7 +28,9 @@ import app.lawnchair.search.algorithms.engine.provider.SettingsSearchProvider
import app.lawnchair.search.algorithms.engine.provider.ShortcutSearchProvider
import app.lawnchair.search.algorithms.engine.provider.web.CustomWebSearchProvider
import app.lawnchair.search.algorithms.engine.provider.web.WebSuggestionProvider
import com.android.internal.R.id.actions
import com.android.launcher3.LauncherAppState
import com.android.launcher3.R
import com.android.launcher3.allapps.BaseAllAppsAdapter
import com.android.launcher3.search.SearchCallback
import com.patrykmichalik.opto.core.firstBlocking
@@ -89,12 +93,40 @@ class LawnchairNewLocalSearchAlgorithm(context: Context) : LawnchairSearchAlgori
override fun doZeroStateSearch(callback: SearchCallback<BaseAllAppsAdapter.AdapterItem>) {
currentJob?.cancel()
currentJob = coroutineScope.launch {
val prefs = PreferenceManager.getInstance(context)
val prefs2 = PreferenceManager2.getInstance(context)
val historyEnabled = prefs.searchResulRecentSuggestion.get()
val maxHistory = prefs2.maxRecentResultCount.firstBlocking()
val historyResults = historySearchProvider.getRecentKeywords(context, maxHistory)
val historyResults = if (historyEnabled) {
historySearchProvider.getRecentKeywords(context, maxHistory)
} else {
emptyList()
}
val searchTargets = translateToSearchTargets(historyResults)
val resultsToTranslate = if (historyResults.isNotEmpty()) {
historyResults + listOf(SearchResult.Action.SearchSettings)
} else {
listOf(
if (historyEnabled) {
// State A: No History
SearchResult.Action.EmptyState(
titleRes = R.string.search_empty_state_title,
subtitleRes = R.string.search_empty_state_no_history_subtitle,
)
} else {
// State B: History Disabled
SearchResult.Action.EmptyState(
titleRes = R.string.search_empty_state_title,
subtitleRes = R.string.search_empty_state_history_disabled_subtitle,
)
},
SearchResult.Action.SearchSettings,
)
}
val searchTargets = translateToSearchTargets(resultsToTranslate)
val adapterItems = transformSearchResults(searchTargets)
withContext(Dispatchers.Main) {
callback.onSearchResult("", ArrayList(adapterItems))
@@ -111,8 +143,6 @@ class LawnchairNewLocalSearchAlgorithm(context: Context) : LawnchairSearchAlgori
val prefs = PreferenceManager.getInstance(context)
val prefs2 = PreferenceManager2.getInstance(context)
actions.add(SearchResult.Action.Divider())
if (prefs.searchResultStartPageSuggestion.get()) {
val provider = prefs2.webSuggestionProvider.firstBlocking()
val webProvider = provider.configure(context)
@@ -137,6 +167,9 @@ class LawnchairNewLocalSearchAlgorithm(context: Context) : LawnchairSearchAlgori
actions.add(SearchResult.Action.MarketSearch(query = query))
}
actions.add(SearchResult.Action.SearchSettings)
actions.add(SearchResult.Action.SearchSettings)
return actions
}
@@ -149,6 +182,8 @@ class LawnchairNewLocalSearchAlgorithm(context: Context) : LawnchairSearchAlgori
SettingsSectionBuilder,
HistorySectionBuilder,
ActionsSectionBuilder,
EmptyStateSectionBuilder,
SearchSettingsSectionBuilder,
)
private fun translateToSearchTargets(

View File

@@ -12,11 +12,13 @@ import app.lawnchair.search.adapter.SearchTargetCompat.Companion.RESULT_TYPE_APP
import app.lawnchair.search.adapter.SearchTargetCompat.Companion.RESULT_TYPE_SHORTCUT
import com.android.app.search.LayoutType.CALCULATOR
import com.android.app.search.LayoutType.EMPTY_DIVIDER
import com.android.app.search.LayoutType.EMPTY_STATE
import com.android.app.search.LayoutType.HORIZONTAL_MEDIUM_TEXT
import com.android.app.search.LayoutType.ICON_HORIZONTAL_TEXT
import com.android.app.search.LayoutType.ICON_SINGLE_VERTICAL_TEXT
import com.android.app.search.LayoutType.ICON_SLICE
import com.android.app.search.LayoutType.PEOPLE_TILE
import com.android.app.search.LayoutType.SEARCH_SETTINGS
import com.android.app.search.LayoutType.SMALL_ICON_HORIZONTAL_TEXT
import com.android.app.search.LayoutType.TEXT_HEADER
import com.android.app.search.LayoutType.THUMBNAIL
@@ -32,6 +34,12 @@ sealed class LawnchairSearchAlgorithm(
protected val context: Context,
) : SearchAlgorithm<BaseAllAppsAdapter.AdapterItem> {
private val transparentBackground = SearchItemBackground(
context,
showBackground = false,
roundBottom = false,
roundTop = false,
)
private val iconBackground = SearchItemBackground(
context,
showBackground = false,
@@ -169,6 +177,8 @@ sealed class LawnchairSearchAlgorithm(
layoutType == ICON_SLICE -> getGroupedBackground(index, settingIndices)
layoutType == WIDGET_LIVE -> getGroupedBackground(index, recentIndices)
layoutType == CALCULATOR && calculator.isNotEmpty() -> normalBackground
layoutType == EMPTY_STATE -> transparentBackground
layoutType == SEARCH_SETTINGS -> transparentBackground
isFirst && isLast -> normalBackground
isFirst -> topBackground
isLast -> bottomBackground

View File

@@ -1,9 +1,8 @@
package app.lawnchair.search.algorithms.engine
import android.annotation.DrawableRes
import android.content.pm.ShortcutInfo
import app.lawnchair.search.adapter.HEADER
import app.lawnchair.search.adapter.SPACE
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import app.lawnchair.search.algorithms.data.ContactInfo
import app.lawnchair.search.algorithms.data.IFileInfo
import app.lawnchair.search.algorithms.data.RecentKeyword
@@ -32,7 +31,10 @@ sealed class SearchResult {
val searchUrl: String,
@DrawableRes val providerIconRes: Int,
) : Action()
data class Header(val title: String, val pkg: String = HEADER) : Action()
data class Divider(val pkg: String = SPACE) : Action()
data class EmptyState(
@StringRes val titleRes: Int,
@StringRes val subtitleRes: Int,
) : Action()
data object SearchSettings : Action()
}
}

View File

@@ -209,3 +209,42 @@ object AppsAndShortcutsSectionBuilder : SectionBuilder {
return targets
}
}
object EmptyStateSectionBuilder : SectionBuilder {
override fun build(
context: Context,
factory: SearchTargetFactory,
results: List<SearchResult>,
): List<SearchTargetCompat> {
val result = results.filterIsInstance<SearchResult.Action.EmptyState>()
val targets = mutableListOf<SearchTargetCompat>()
result.firstOrNull()?.let {
targets.add(
factory.createEmptyStateTarget(
it.titleRes,
it.subtitleRes,
),
)
}
return targets
}
}
object SearchSettingsSectionBuilder : SectionBuilder {
override fun build(
context: Context,
factory: SearchTargetFactory,
results: List<SearchResult>,
): List<SearchTargetCompat> {
val result = results.filterIsInstance<SearchResult.Action.SearchSettings>()
val targets = mutableListOf<SearchTargetCompat>()
result.firstOrNull()?.let {
targets.add(
factory.createSearchSettingsTarget(),
)
}
return targets
}
}