Compose Searchbar (#237)

* Migrate to Compose SearchBar

* Fix searchbar padding

* Fix content padding

* Implement scroll away behaviour

* Reset searchBarOffsetY when expand

* Fix multiple listener

* Fix cursor position

* Fix spotless

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
This commit is contained in:
authored and GitHub committed 2023-11-11 13:03:11 +00:00
1 parent a9bd55dff5
commit 4e08f4f962
22 files changed
+856 -661

No files matched your search

@@ -0,0 +1,379 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("ktlint:standard:property-naming")
package androidx.compose.material3
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.MutableWindowInsets
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text2.BasicTextField2
import androidx.compose.foundation.text2.input.TextFieldLineLimits
import androidx.compose.foundation.text2.input.TextFieldState
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.offset
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
import kotlin.math.max
import kotlin.math.min
import kotlinx.coroutines.delay
/**
* <a href="https://m3.material.io/components/search/overview" class="external" target="_blank">Material Design search</a>.
*
* A search bar represents a floating search field that allows users to enter a keyword or phrase
* and get relevant information. It can be used as a way to navigate through an app via search
* queries.
*
* An active search bar expands into a search "view" and can be used to display dynamic suggestions.
*
* ![Search bar image](https://developer.android.com/images/reference/androidx/compose/material3/search-bar.png)
*
* A [SearchBar] expands to occupy the entirety of its allowed size when active. For full-screen
* behavior as specified by Material guidelines, parent layouts of the [SearchBar] must not pass
* any [Constraints] that limit its size, and the host activity should set
* `WindowCompat.setDecorFitsSystemWindows(window, false)`.
*
* If this expansion behavior is undesirable, for example on large tablet screens, [DockedSearchBar]
* can be used instead.
*
* @param state the query text to be shown in the search bar's input field
* @param onSearch the callback to be invoked when the input service triggers the [ImeAction.Search]
* action.
* @param active whether this search bar is active
* @param onActiveChange the callback to be invoked when this search bar's active state is changed
* @param modifier the [Modifier] to be applied to this search bar
* @param enabled controls the enabled state of this search bar. When `false`, this component will
* not respond to user input, and it will appear visually disabled and disabled to accessibility
* services.
* @param placeholder the placeholder to be displayed when the search bar's query is empty.
* @param leadingIcon the leading icon to be displayed at the beginning of the search bar container
* @param trailingIcon the trailing icon to be displayed at the end of the search bar container
* @param shape the shape of this search bar when it is not [active]. When [active], the shape will
* always be [SearchBarDefaults.fullScreenShape].
* @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
* in different states. See [SearchBarDefaults.colors].
* @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
* translucent primary color overlay is applied on top of the container. A higher tonal elevation
* value will result in a darker color in light theme and lighter color in dark theme. See also:
* [Surface].
* @param shadowElevation the elevation for the shadow below the search bar
* @param windowInsets the window insets that the search bar will respect
* @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
* for this search bar. You can create and pass in your own `remember`ed instance to observe
* [Interaction]s and customize the appearance / behavior of this search bar in different states.
* @param content the content of this search bar that will be displayed below the input field
*/
@ExperimentalMaterial3Api
@Composable
fun SearchBar(
state: TextFieldState,
onSearch: () -> Unit,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
shape: Shape = SearchBarDefaults.inputFieldShape,
colors: SearchBarColors = SearchBarDefaults.colors(),
tonalElevation: Dp = SearchBarDefaults.TonalElevation,
shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable ColumnScope.() -> Unit,
) {
val animationProgress: State<Float> = animateFloatAsState(
targetValue = if (active) 1f else 0f,
animationSpec = if (active) AnimationEnterFloatSpec else AnimationExitFloatSpec,
label = "SearchBarAnimation",
)
val focusManager = LocalFocusManager.current
val density = LocalDensity.current
val defaultInputFieldShape = SearchBarDefaults.inputFieldShape
val defaultFullScreenShape = SearchBarDefaults.fullScreenShape
val useFullScreenShape by remember {
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value == 1f }
}
val animatedShape = remember(useFullScreenShape, shape) {
when {
shape == defaultInputFieldShape ->
// The shape can only be animated if it's the default spec value
GenericShape { size, _ ->
val radius = with(density) {
(SearchBarCornerRadius * (1 - animationProgress.value)).toPx()
}
addRoundRect(RoundRect(size.toRect(), CornerRadius(radius)))
}
useFullScreenShape -> defaultFullScreenShape
else -> shape
}
}
// The main animation complexity is allowing the component to smoothly expand while keeping the
// input field at the same relative location on screen. `Modifier.windowInsetsPadding` does not
// support animation and thus is not suitable. Instead, we convert the insets to a padding
// applied to the Surface, which gradually becomes padding applied to the input field as the
// animation proceeds.
val unconsumedInsets = remember { MutableWindowInsets() }
val topPadding = remember(density) {
derivedStateOf {
SearchBarVerticalPadding +
unconsumedInsets.asPaddingValues(density).calculateTopPadding()
}
}
Surface(
shape = animatedShape,
color = colors.containerColor,
contentColor = contentColorFor(colors.containerColor),
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
modifier = modifier
.zIndex(1f)
.onConsumedWindowInsetsChanged { consumedInsets ->
unconsumedInsets.insets = windowInsets.exclude(consumedInsets)
}
.consumeWindowInsets(unconsumedInsets)
.layout { measurable, constraints ->
val animatedTopPadding =
lerp(topPadding.value, 0.dp, animationProgress.value).roundToPx()
val startWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx())
.coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx()))
val startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx())
.coerceAtMost(constraints.maxHeight)
val endWidth = constraints.maxWidth
val endHeight = constraints.maxHeight
val width = lerp(startWidth, endWidth, animationProgress.value)
val height =
lerp(startHeight, endHeight, animationProgress.value) + animatedTopPadding
val placeable = measurable.measure(
Constraints.fixed(width, height).offset(vertical = -animatedTopPadding),
)
layout(width, height) {
placeable.placeRelative(0, animatedTopPadding)
}
},
) {
Column {
val animatedInputFieldPadding = remember {
AnimatedPaddingValues2(animationProgress, topPadding)
}
SearchBarInputField(
state = state,
onSearch = onSearch,
onActiveChange = onActiveChange,
modifier = Modifier.padding(paddingValues = animatedInputFieldPadding),
enabled = enabled,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
colors = colors.inputFieldColors,
interactionSource = interactionSource,
)
val showResults by remember {
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value > 0 }
}
if (showResults) {
Column(Modifier.graphicsLayer { alpha = animationProgress.value }) {
HorizontalDivider(color = colors.dividerColor)
content()
}
}
}
}
val isFocused = interactionSource.collectIsFocusedAsState().value
val shouldClearFocus = !active && isFocused
LaunchedEffect(active) {
if (shouldClearFocus) {
// Not strictly needed according to the motion spec, but since the animation already has
// a delay, this works around b/261632544.
delay(AnimationDelayMillis.toLong())
focusManager.clearFocus()
}
}
BackHandler(enabled = active) {
onActiveChange(false)
}
}
@Composable
private fun SearchBarInputField(
state: TextFieldState,
onSearch: () -> Unit,
onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusRequester = remember { FocusRequester() }
val focused by interactionSource.collectIsFocusedAsState()
val textColor = LocalTextStyle.current.color.takeOrElse {
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
}
BasicTextField2(
state = state,
modifier = modifier
.height(InputFieldHeight)
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { if (it.isFocused) onActiveChange(true) }
.semantics {
onClick {
focusRequester.requestFocus()
true
}
},
enabled = enabled,
lineLimits = TextFieldLineLimits.SingleLine,
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
interactionSource = interactionSource,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.DecorationBox(
value = state.text.toString(),
innerTextField = innerTextField,
enabled = enabled,
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
placeholder = placeholder,
leadingIcon = leadingIcon?.let { leading ->
{
Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() }
}
},
trailingIcon = trailingIcon?.let { trailing ->
{
Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() }
}
},
shape = SearchBarDefaults.inputFieldShape,
colors = colors,
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
container = {},
)
},
)
}
@Stable
private class AnimatedPaddingValues2(
val animationProgress: State<Float>,
val topPadding: State<Dp>,
) : PaddingValues {
override fun calculateTopPadding(): Dp = topPadding.value * animationProgress.value
override fun calculateBottomPadding(): Dp = SearchBarVerticalPadding * animationProgress.value
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 0.dp
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 0.dp
}
// Measurement specs
private val SearchBarCornerRadius: Dp = InputFieldHeight / 2
internal val SearchBarMinWidth: Dp = 360.dp
private val SearchBarMaxWidth: Dp = 720.dp
internal val SearchBarVerticalPadding: Dp = 8.dp
// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp.
private val SearchBarIconOffsetX: Dp = 4.dp
// Animation specs
private const val AnimationEnterDurationMillis: Int = 600
private const val AnimationExitDurationMillis: Int = 350
private const val AnimationDelayMillis: Int = 100
private val AnimationEnterEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)
private val AnimationExitEasing = CubicBezierEasing(0.0f, 1.0f, 0.0f, 1.0f)
private val AnimationEnterFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationEnterDurationMillis,
delayMillis = AnimationDelayMillis,
easing = AnimationEnterEasing,
)
private val AnimationExitFloatSpec: FiniteAnimationSpec<Float> = tween(
durationMillis = AnimationExitDurationMillis,
delayMillis = AnimationDelayMillis,
easing = AnimationExitEasing,
)
@@ -34,7 +34,14 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Text
@@ -48,10 +55,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachReversed
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
@@ -60,6 +69,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import androidx.viewbinding.ViewBinding
import arrow.core.partially1
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
@@ -105,8 +115,8 @@ import com.hippo.ehviewer.util.containsIgnoreCase
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.pxToDp
import kotlinx.coroutines.Dispatchers
@@ -144,7 +154,6 @@ class DownloadsScene :
override val fabLayout get() = binding.fabLayout
override val fastScroller get() = binding.fastScroller
override val recyclerView get() = binding.recyclerView
override val contentView get() = binding.content
private fun initLabels() {
context ?: return
@@ -247,13 +256,11 @@ class DownloadsScene :
override fun onCreateViewWithToolbar(
inflater: LayoutInflater,
container: ViewGroup?,
container: ViewGroup,
savedInstanceState: Bundle?,
): View {
_binding = SceneDownloadBinding.inflate(inflater, container!!)
container.addView(ComposeView(inflater.context).apply { setMD3Content { dialogState.Intercept() } })
): ViewBinding {
_binding = SceneDownloadBinding.inflate(inflater, container)
binding.run {
setLiftOnScrollTargetView(recyclerView)
mViewTransition = ViewTransition(content, tip)
val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.big_download)
drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
@@ -357,7 +364,7 @@ class DownloadsScene :
mKeyword = it.takeUnless { it.isEmpty() }
updateInfoList()
}
return binding.root
return binding
}
override fun onSearchViewExpanded() {
@@ -375,78 +382,84 @@ class DownloadsScene :
_binding = null
}
override fun onNavigationClick() {
openDrawer()
}
override fun getMenuResId(): Int {
return R.menu.scene_download
}
override fun onMenuItemClick(item: MenuItem): Boolean {
// Skip when in choice mode
val activity: Activity? = mainActivity
if (null == activity || tracker.isInCustomChoice) {
return false
@Composable
override fun TrailingIcon() {
dialogState.Intercept()
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { openSideSheet() }) {
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = stringResource(id = R.string.download_labels))
}
when (item.itemId) {
R.id.action_filter -> {
BaseDialogBuilder(requireActivity())
.setSingleChoiceItems(
R.array.download_state,
mType + 1,
) { dialog: DialogInterface, which: Int ->
mType = which - 1
updateInfoList()
dialog.dismiss()
IconButton(onClick = { expanded = !expanded }) {
Icon(imageVector = Icons.Default.MoreVert, contentDescription = null)
}
val states = stringArrayResource(id = R.array.download_state)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_filter)) },
onClick = {
expanded = false
lifecycleScope.launch {
val type = dialogState.showSingleChoice(states, mType)
if (type != mType) {
updateInfoList()
}
}
.show()
return true
}
R.id.action_start_all -> {
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START_ALL
ContextCompat.startForegroundService(activity, intent)
return true
}
R.id.action_stop_all -> {
lifecycleScope.launchIO {
DownloadManager.stopAllDownload()
}
return true
}
R.id.action_reset_reading_progress -> {
BaseDialogBuilder(requireContext())
.setMessage(R.string.reset_reading_progress_message)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
lifecycleScope.launchNonCancellable {
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_start_all)) },
onClick = {
expanded = false
val activity = requireActivity()
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START_ALL
ContextCompat.startForegroundService(activity, intent)
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_stop_all)) },
onClick = {
expanded = false
lifecycleScope.launchIO {
DownloadManager.stopAllDownload()
}
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_reset_reading_progress)) },
onClick = {
expanded = false
lifecycleScope.launchIO {
dialogState.awaitPermissionOrCancel(
confirmText = android.R.string.ok,
dismissText = android.R.string.cancel,
) {
Text(text = stringResource(id = R.string.reset_reading_progress_message))
}
withNonCancellableContext {
DownloadManager.resetAllReadingProgress()
}
}.show()
return true
}
R.id.action_start_all_reversed -> {
val list = mList ?: return true
val gidList = LongList()
for (i in list.size - 1 downTo 0) {
val info = list[i]
if (info.state != DownloadInfo.STATE_FINISH) {
gidList.add(info.gid)
}
}
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START_RANGE
intent.putExtra(DownloadService.KEY_GID_LIST, gidList)
ContextCompat.startForegroundService(activity, intent)
return true
}
else -> return super.onMenuItemClick(item)
},
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.download_start_all_reversed)) },
onClick = {
expanded = false
val list = mList ?: return@DropdownMenuItem
val activity = requireActivity()
val gidList = LongList()
list.fastForEachReversed { info ->
if (info.state != DownloadInfo.STATE_FINISH) {
gidList.add(info.gid)
}
}
val intent = Intent(activity, DownloadService::class.java)
intent.action = DownloadService.ACTION_START_RANGE
intent.putExtra(DownloadService.KEY_GID_LIST, gidList)
ContextCompat.startForegroundService(activity, intent)
},
)
}
}
@@ -18,16 +18,20 @@ package com.hippo.ehviewer.ui.scene
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FolderSpecial
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.currentRecomposeScope
@@ -50,6 +54,7 @@ import androidx.paging.PagingState
import androidx.paging.cachedIn
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.CalendarConstraints.DateValidator
import com.google.android.material.datepicker.CompositeDateValidator
@@ -73,7 +78,6 @@ import com.hippo.ehviewer.ui.legacy.AddDeleteDrawable
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
import com.hippo.ehviewer.ui.legacy.FabLayout
import com.hippo.ehviewer.ui.legacy.FabLayout.OnClickFabListener
import com.hippo.ehviewer.ui.legacy.FastScroller.OnDragHandlerListener
import com.hippo.ehviewer.ui.legacy.HandlerDrawable
import com.hippo.ehviewer.ui.legacy.ViewTransition
import com.hippo.ehviewer.ui.legacy.WindowInsetsAnimationHelper
@@ -160,7 +164,6 @@ class FavoritesScene : SearchBarScene() {
override val fabLayout get() = binding.fabLayout
override val fastScroller get() = binding.fastScroller
override val recyclerView get() = binding.recyclerView
override val contentView get() = binding.contentLayout.contentView
private fun onItemClick(position: Int) {
// Skip if in search mode
@@ -201,7 +204,13 @@ class FavoritesScene : SearchBarScene() {
Settings.recentFavCat = urlBuilder.favCat
}
override fun getMenuResId() = R.menu.scene_favorites
@Composable
override fun TrailingIcon() {
dialogState.Intercept()
IconButton(onClick = { openSideSheet() }) {
Icon(imageVector = Icons.Outlined.FolderSpecial, contentDescription = stringResource(id = R.string.collections))
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
@@ -221,12 +230,10 @@ class FavoritesScene : SearchBarScene() {
override fun onCreateViewWithToolbar(
inflater: LayoutInflater,
container: ViewGroup?,
container: ViewGroup,
savedInstanceState: Bundle?,
): View {
_binding = SceneFavoritesBinding.inflate(inflater, container!!)
val stub = ComposeWithMD3 { dialogState.Intercept() }
container.addView(stub)
): ViewBinding {
_binding = SceneFavoritesBinding.inflate(inflater, container)
setOnApplySearch {
if (!tracker.isInCustomChoice) {
switchFav(urlBuilder.favCat, it)
@@ -303,12 +310,6 @@ class FavoritesScene : SearchBarScene() {
}
}
}
binding.fastScroller.setOnDragHandlerListener(object : OnDragHandlerListener {
override fun onStartDragHandler() {}
override fun onEndDragHandler() {
showSearchBar()
}
})
binding.recyclerView.run {
mAdapter = GalleryAdapter(
this@run,
@@ -336,7 +337,6 @@ class FavoritesScene : SearchBarScene() {
adapter.loadStateFlow.collectLatest {
when (val state = it.refresh) {
is LoadState.Loading -> {
showSearchBar()
if (!binding.refreshLayout.isRefreshing) {
transition.showView(1)
}
@@ -416,7 +416,7 @@ class FavoritesScene : SearchBarScene() {
}
})
itemTouchHelper.attachToRecyclerView(binding.recyclerView)
return binding.root
return binding
}
// Hide jump fab on local fav cat
@@ -27,12 +27,16 @@ import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
@@ -47,6 +51,7 @@ import androidx.paging.cachedIn
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.CalendarConstraints.DateValidator
import com.google.android.material.datepicker.CompositeDateValidator
@@ -85,19 +90,15 @@ import com.hippo.ehviewer.ui.legacy.BringOutTransition
import com.hippo.ehviewer.ui.legacy.EditTextDialogBuilder
import com.hippo.ehviewer.ui.legacy.FabLayout
import com.hippo.ehviewer.ui.legacy.FabLayout.OnClickFabListener
import com.hippo.ehviewer.ui.legacy.FastScroller.OnDragHandlerListener
import com.hippo.ehviewer.ui.legacy.HandlerDrawable
import com.hippo.ehviewer.ui.legacy.LayoutManagerUtils.firstVisibleItemPosition
import com.hippo.ehviewer.ui.legacy.ViewTransition
import com.hippo.ehviewer.ui.legacy.WindowInsetsAnimationHelper
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.util.ExceptionUtils
import com.hippo.ehviewer.util.applyNavigationBarsPadding
import com.hippo.ehviewer.util.getParcelableCompat
import com.hippo.ehviewer.util.getValue
import com.hippo.ehviewer.util.lazyMut
import com.hippo.ehviewer.util.setValue
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
@@ -197,11 +198,6 @@ class GalleryListScene : SearchBarScene() {
override val fabLayout get() = binding.fabLayout
override val fastScroller get() = binding.fastScroller
override val recyclerView get() = binding.recyclerView
override val contentView get() = binding.contentLayout.contentView
override fun getMenuResId(): Int {
return R.menu.scene_gallery_list_searchbar_menu
}
private fun handleArgs(args: Bundle?) {
val action = args?.getString(KEY_ACTION) ?: ACTION_HOMEPAGE
@@ -293,30 +289,15 @@ class GalleryListScene : SearchBarScene() {
override fun onCreateViewWithToolbar(
inflater: LayoutInflater,
container: ViewGroup?,
container: ViewGroup,
savedInstanceState: Bundle?,
): View {
_binding = SceneGalleryListBinding.inflate(inflater, container!!)
container.addView(ComposeWithMD3 { dialogState.Intercept() })
): ViewBinding {
_binding = SceneGalleryListBinding.inflate(inflater, container)
requireActivity().onBackPressedDispatcher.addCallback(stateBackPressedCallback)
mHideActionFabSlop = ViewConfiguration.get(requireContext()).scaledTouchSlop
ViewCompat.setWindowInsetsAnimationCallback(
binding.root,
WindowInsetsAnimationHelper(
WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP,
binding.fabLayout,
binding.searchFabLayout,
),
)
mViewTransition = BringOutTransition(binding.contentLayout.contentView, binding.searchLayout)
binding.searchLayout.consumeWindowInsets = false
binding.searchLayout.setViewTreeViewModelStoreOwner(this)
binding.fastScroller.setOnDragHandlerListener(object : OnDragHandlerListener {
override fun onStartDragHandler() {}
override fun onEndDragHandler() {
showSearchBar()
}
})
mAdapter = GalleryAdapter(
binding.recyclerView,
true,
@@ -352,7 +333,6 @@ class GalleryListScene : SearchBarScene() {
adapter.loadStateFlow.collectLatest {
when (val state = it.refresh) {
is LoadState.Loading -> {
showSearchBar()
if (!binding.refreshLayout.isRefreshing) {
transition.showView(1)
}
@@ -456,16 +436,6 @@ class GalleryListScene : SearchBarScene() {
}
}
binding.fabLayout.primaryFab.setImageDrawable(actionFabDrawable)
binding.searchFab.setOnClickListener { onApplySearch() }
binding.searchFabLayout.apply {
bringToFront()
applyNavigationBarsPadding()
}
binding.searchLayout.applyInsetter {
type(statusBars = true) {
padding()
}
}
// Update list url builder
onUpdateUrlBuilder()
@@ -474,7 +444,7 @@ class GalleryListScene : SearchBarScene() {
val newState = mState
mState = State.NORMAL
setState(newState, false)
return binding.root
return binding
}
override fun onDestroyView() {
@@ -680,21 +650,29 @@ class GalleryListScene : SearchBarScene() {
}
}
private fun showSearchFab(delay: Long) {
viewLifecycleOwner.lifecycleScope.launch {
delay(delay)
showSearchFab = true
}
}
private fun selectSearchFab(animation: Boolean) {
val delay = binding.fabLayout.hide(animation)
binding.searchFabLayout.show(animation, delay)
showSearchFab(delay)
}
private fun selectActionFab(animation: Boolean) {
val delay = binding.searchFabLayout.hide(animation)
binding.fabLayout.show(animation, delay)
showSearchFab = false
binding.fabLayout.show(animation, FabLayout.ANIMATE_TIME)
}
override fun onSearchViewExpanded() {
if (mState == State.NORMAL) {
binding.fabLayout.hide()
} else {
binding.searchFabLayout.hide()
showSearchFab = false
stateBackPressedCallback.isEnabled = false
}
super.onSearchViewExpanded()
}
@@ -704,7 +682,8 @@ class GalleryListScene : SearchBarScene() {
if (mState == State.NORMAL) {
binding.fabLayout.show(delay = SEARCH_VIEW_ANIMATE_TIME)
} else {
binding.searchFabLayout.show(delay = SEARCH_VIEW_ANIMATE_TIME)
showSearchFab(SEARCH_VIEW_ANIMATE_TIME)
stateBackPressedCallback.isEnabled = true
}
}
@@ -722,11 +701,7 @@ class GalleryListScene : SearchBarScene() {
}
}
private fun setState(state: State) {
setState(state, true)
}
private fun setState(state: State, animation: Boolean) {
private fun setState(state: State, animation: Boolean = true) {
_binding ?: return
if (null == mViewTransition) {
return
@@ -734,7 +709,6 @@ class GalleryListScene : SearchBarScene() {
if (mState != state) {
val oldState = mState
mState = state
showSearchBar()
onStateChange(state)
when (oldState) {
State.NORMAL -> when (state) {
@@ -767,16 +741,20 @@ class GalleryListScene : SearchBarScene() {
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return if (item.itemId == R.id.action_search_more) {
@Composable
override fun TrailingIcon() {
dialogState.Intercept()
IconButton(onClick = { openSideSheet() }) {
Icon(imageVector = Icons.Outlined.Bookmarks, contentDescription = stringResource(id = R.string.quick_search))
}
IconButton(onClick = {
if (mState == State.NORMAL) {
setState(State.SEARCH)
} else {
setState(State.NORMAL)
}
true
} else {
super.onMenuItemClick(item)
}) {
Icon(imageVector = Icons.Default.Add, contentDescription = null)
}
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.scene
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
@@ -8,6 +9,7 @@ import com.hippo.ehviewer.ui.setMD3Content
@Suppress("FunctionName")
inline fun Fragment.ComposeWithMD3(crossinline content: @Composable () -> Unit) = ComposeView(requireContext()).apply {
consumeWindowInsets = false
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner))
setMD3Content(content)
}
@@ -2,86 +2,272 @@ package com.hippo.ehviewer.ui.scene
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.annotation.CallSuper
import androidx.annotation.Keep
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text2.input.clearText
import androidx.compose.foundation.text2.input.forEachTextValue
import androidx.compose.foundation.text2.input.rememberTextFieldState
import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import com.google.android.material.search.SearchView
import com.google.android.material.shape.MaterialShapeDrawable
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidViewBinding
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.OnScrollListener
import androidx.viewbinding.ViewBinding
import com.hippo.ehviewer.EhApplication.Companion.searchDatabase
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.dao.Search
import com.hippo.ehviewer.dao.SearchDao
import com.hippo.ehviewer.databinding.SceneSearchbarBinding
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
import com.hippo.ehviewer.ui.legacy.FabLayout
import com.hippo.ehviewer.ui.setMD3Content
import com.hippo.ehviewer.util.applyNavigationBarsPadding
import com.hippo.ehviewer.util.isAtLeastT
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.util.findActivity
import com.jamal.composeprefs3.ui.ifNotNullThen
import com.jamal.composeprefs3.ui.ifTrueThen
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
abstract class SearchBarScene : BaseScene(), ToolBarScene {
private var _binding: SceneSearchbarBinding? = null
fun interface SuggestionProvider {
fun providerSuggestions(text: String): Suggestion?
}
@get:Keep
private val binding get() = _binding!!
private var mSuggestionList by mutableStateOf(emptyList<Suggestion>())
private var mSuggestionProvider: SuggestionProvider? = null
private var allowEmptySearch = true
private val mSearchDatabase = searchDatabase.searchDao()
private var onApplySearch: (String) -> Unit = {}
abstract class Suggestion {
abstract val keyword: String
open val hint: String? = null
abstract fun onClick()
open val canDelete: Boolean = false
open val canOpenDirectly: Boolean = false
}
protected abstract val fabLayout: FabLayout
protected abstract val fastScroller: View
protected abstract val recyclerView: View
protected abstract val contentView: View
suspend fun SearchDao.suggestions(prefix: String, limit: Int) =
(if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit)).map { it.query }
override val enableDrawerGestures = true
@Composable
fun SearchBarScreen(
initialQuery: String = "",
hint: String = "",
showSearchFab: Boolean = false,
onApplySearch: (String) -> Unit,
onSearchExpanded: () -> Unit,
onSearchHidden: () -> Unit,
suggestionProvider: SuggestionProvider? = null,
searchBarOffsetY: MutableState<Int>,
trailingIcon: @Composable () -> Unit,
content: @Composable (PaddingValues) -> Unit,
) {
var mSuggestionList by remember { mutableStateOf(emptyList<Suggestion>()) }
val mSearchDatabase = searchDatabase.searchDao()
val searchFieldState = rememberTextFieldState(initialQuery)
var active by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val activity = context.findActivity<MainActivity>()
val dialogState = LocalDialogState.current
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SceneSearchbarBinding.inflate(inflater, container, false)
binding.appbar.statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(context)
binding.searchview.editText.addTextChangedListener { updateSuggestions() }
binding.searchview.editText.setOnEditorActionListener { _, _, _ ->
onApplySearch()
true
class TagSuggestion(
override val hint: String?,
override val keyword: String,
) : Suggestion() {
override fun onClick() {
val query = searchFieldState.text.toString()
var keywords = query.substringBeforeLast(' ', "")
if (keywords.isNotEmpty()) keywords += ' '
keywords += wrapTagKeyword(keyword)
if (!keywords.endsWith(':')) keywords += ' '
searchFieldState.setTextAndPlaceCursorAtEnd(keywords)
}
binding.searchBarList.consumeWindowInsets = false
binding.searchBarList.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner))
binding.searchBarList.setMD3Content {
LazyColumn(modifier = Modifier.navigationBarsPadding().imePadding()) {
}
class KeywordSuggestion(
override val keyword: String,
) : Suggestion() {
override val canDelete = true
override fun onClick() {
searchFieldState.setTextAndPlaceCursorAtEnd(keyword)
}
}
fun mergedSuggestionFlow(): Flow<Suggestion> = flow {
val query = searchFieldState.text.toString()
suggestionProvider?.run { providerSuggestions(query)?.let { emit(it) } }
mSearchDatabase.suggestions(query, 128).forEach { emit(KeywordSuggestion(it)) }
EhTagDatabase.takeIf { it.initialized }?.run {
if (query.isNotEmpty() && !query.endsWith(' ')) {
val keyword = query.substringAfterLast(' ')
val translate = Settings.showTagTranslations && isTranslatable(context)
suggestFlow(keyword, translate, true).collect {
emit(TagSuggestion(it.first, it.second))
}
suggestFlow(keyword, translate).collect {
emit(TagSuggestion(it.first, it.second))
}
}
}
}
suspend fun updateSuggestions() {
mSuggestionList = mergedSuggestionFlow().toList()
}
var drawerLocked by remember { mutableStateOf(false) }
fun onSearchViewExpanded() {
onSearchExpanded()
if (!activity.drawerLocked) {
drawerLocked = true
activity.drawerLocked = true
}
}
fun onSearchViewHidden() {
if (drawerLocked) {
activity.drawerLocked = false
drawerLocked = false
}
onSearchHidden()
}
if (active) {
LaunchedEffect(Unit) {
searchFieldState.forEachTextValue {
updateSuggestions()
}
}
}
fun hideSearchView() {
onSearchViewHidden()
active = false
}
fun onApplySearch() {
val query = searchFieldState.text.trim().toString()
if (query.isNotEmpty()) {
scope.launchIO {
mSearchDatabase.deleteQuery(query)
val search = Search(System.currentTimeMillis(), query)
mSearchDatabase.insert(search)
}
}
onApplySearch(query)
}
fun deleteKeyword(keyword: String) {
scope.launch {
dialogState.awaitPermissionOrCancel(
confirmText = R.string.delete,
) {
Text(text = stringResource(id = R.string.delete_search_history, keyword))
}
mSearchDatabase.deleteQuery(keyword)
updateSuggestions()
}
}
var searchbarOfs by searchBarOffsetY
Box(modifier = Modifier.fillMaxSize()) {
SearchBar(
modifier = Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(0, searchbarOfs)
}
}.align(Alignment.TopCenter),
state = searchFieldState,
onSearch = {
hideSearchView()
onApplySearch()
},
active = active,
onActiveChange = {
if (it) {
searchbarOfs = 0
onSearchViewExpanded()
} else {
onSearchViewHidden()
}
active = it
},
placeholder = { Text(hint) },
leadingIcon = {
if (active) {
IconButton(onClick = { hideSearchView() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
}
} else {
IconButton(onClick = { activity.openDrawer() }) {
Icon(Icons.Default.Menu, contentDescription = null)
}
}
},
trailingIcon = {
if (active) {
if (searchFieldState.text.isNotEmpty()) {
IconButton(onClick = { searchFieldState.clearText() }) {
Icon(Icons.Default.Close, contentDescription = null)
}
}
} else {
Row {
trailingIcon()
}
}
},
) {
LazyColumn(
modifier = Modifier.imePadding(),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
items(mSuggestionList) {
ListItem(
headlineContent = { Text(text = it.keyword) },
@@ -106,233 +292,116 @@ abstract class SearchBarScene : BaseScene(), ToolBarScene {
}
}
}
binding.searchview.addTransitionListener { _, _, newState ->
if (newState == SearchView.TransitionState.SHOWING) {
onSearchViewExpanded()
} else if (newState == SearchView.TransitionState.HIDING) {
onSearchViewHidden()
Scaffold(
floatingActionButton = {
AnimatedVisibility(
visible = showSearchFab,
enter = scaleIn(),
exit = scaleOut(),
) {
FloatingActionButton(onClick = ::onApplySearch) {
Icon(imageVector = Icons.Default.Search, contentDescription = null)
}
}
},
) {
content(
PaddingValues(
top = it.calculateTopPadding() + 64.dp,
bottom = it.calculateBottomPadding(),
),
)
}
}
}
abstract class SearchBarScene : BaseScene() {
private var mSuggestionProvider: SuggestionProvider? = null
private var onApplySearch: (String) -> Unit = {}
private var query by mutableStateOf("")
private var hint by mutableStateOf("")
var showSearchFab by mutableStateOf(false)
protected abstract val fabLayout: View
protected abstract val fastScroller: View
protected abstract val recyclerView: RecyclerView
override val enableDrawerGestures = true
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val ofs = mutableIntStateOf(0)
val onScrollListener = object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
var toChange by ofs
toChange = (toChange - dy).coerceIn(-300, 0)
}
}
onCreateViewWithToolbar(inflater, binding.root, savedInstanceState)
if (!isAtLeastT) {
// This has to be placed after onCreateViewWithToolbar() since
// callbacks are invoked in the reverse order in which they are added
binding.searchview.addTransitionListener(mSearchViewOnBackPressedCallback)
requireActivity().onBackPressedDispatcher.addCallback(mSearchViewOnBackPressedCallback)
}
binding.appbar.bringToFront()
fabLayout.applyNavigationBarsPadding()
fastScroller.applyNavigationBarsPadding()
recyclerView.applyNavigationBarsPadding()
contentView.applyInsetter {
type(statusBars = true) {
padding()
return ComposeWithMD3 {
val density = LocalDensity.current
val fabPadding = with(density) { 16.dp.roundToPx() }
SearchBarScreen(
initialQuery = query,
hint = hint,
showSearchFab = showSearchFab,
onApplySearch = onApplySearch,
onSearchExpanded = ::onSearchViewExpanded,
onSearchHidden = ::onSearchViewHidden,
suggestionProvider = mSuggestionProvider,
searchBarOffsetY = ofs,
trailingIcon = {
TrailingIcon()
},
) { contentPadding ->
val bottomPadding = with(density) { contentPadding.calculateBottomPadding().roundToPx() }
AndroidViewBinding(
modifier = Modifier.padding(top = contentPadding.calculateTopPadding()).fillMaxSize(),
factory = { inflater, parent, _ ->
parent.clipChildren = false
onCreateViewWithToolbar(inflater, parent, savedInstanceState).apply {
recyclerView.addOnScrollListener(onScrollListener)
}
},
) {
fabLayout.updatePadding(bottom = fabPadding + bottomPadding)
fastScroller.updatePadding(bottom = bottomPadding)
recyclerView.updatePadding(bottom = bottomPadding)
}
}
}
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.apply {
val menuResId = getMenuResId()
if (menuResId != 0) {
inflateMenu(menuResId)
setOnMenuItemClickListener { item: MenuItem -> onMenuItemClick(item) }
}
setNavigationOnClickListener { onNavigationClick() }
}
}
abstract fun onCreateViewWithToolbar(
inflater: LayoutInflater,
container: ViewGroup,
savedInstanceState: Bundle?,
): ViewBinding
override fun onDestroyView() {
super.onDestroyView()
if (!isAtLeastT) mSearchViewOnBackPressedCallback.remove()
binding.root.removeAllViews()
_binding = null
}
@Composable
abstract fun TrailingIcon()
private var drawerLocked = false
open fun onSearchViewExpanded() {}
@CallSuper
open fun onSearchViewExpanded() {
if (!isDrawerLocked) {
drawerLocked = true
lockDrawer()
}
updateSuggestions()
}
@CallSuper
open fun onSearchViewHidden() {
binding.toolbar.setText(binding.searchview.text)
if (drawerLocked) {
unlockDrawer()
drawerLocked = false
}
}
open fun onSearchViewHidden() {}
fun setSearchBarHint(hint: String?) {
binding.toolbar.hint = hint
this.hint = hint.orEmpty()
}
fun setSearchBarText(text: String?) {
binding.toolbar.setText(text)
binding.searchview.setText(text)
query = text.orEmpty()
}
fun setEditTextHint(hint: String?) {
binding.searchview.editText.hint = hint
}
override fun onNavigationClick() {
openDrawer()
}
override fun getMenuResId(): Int {
return 0
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return if (item.itemId == R.id.action_open_side_sheet) {
openSideSheet()
true
} else {
false
}
}
override fun setLiftOnScrollTargetView(view: View?) {
binding.appbar.setLiftOnScrollTargetView(view)
this.hint = hint.orEmpty()
}
fun setOnApplySearch(lambda: (String) -> Unit) {
onApplySearch = lambda
}
private suspend fun addQuery(query: String) {
mSearchDatabase.deleteQuery(query)
if (query.isBlank()) return
val search = Search(System.currentTimeMillis(), query)
mSearchDatabase.insert(search)
}
fun onApplySearch() {
binding.toolbar.setText(binding.searchview.text)
binding.searchview.hide()
val query = binding.toolbar.text.toString().trim()
if (!allowEmptySearch && query.isEmpty()) return
lifecycleScope.launchIO { addQuery(query) }
onApplySearch(query)
}
fun interface SuggestionProvider {
fun providerSuggestions(text: String): Suggestion?
}
private fun deleteKeyword(keyword: String) {
BaseDialogBuilder(requireContext())
.setMessage(requireContext().getString(R.string.delete_search_history, keyword))
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.delete) { _, _ ->
lifecycleScope.launchIO {
mSearchDatabase.deleteQuery(keyword)
updateSuggestions()
}
}
.show()
}
abstract class Suggestion {
abstract val keyword: String
open val hint: String? = null
abstract fun onClick()
open val canDelete: Boolean = false
open val canOpenDirectly: Boolean = false
}
inner class TagSuggestion(
override val hint: String?,
override val keyword: String,
) : Suggestion() {
override fun onClick() {
binding.searchview.editText.let {
var keywords = it.text.toString().substringBeforeLast(' ', "")
if (keywords.isNotEmpty()) keywords += ' '
keywords += wrapTagKeyword(keyword)
if (!keywords.endsWith(':')) keywords += ' '
it.setText(keywords)
it.setSelection(keywords.length)
}
}
}
inner class KeywordSuggestion(
override val keyword: String,
) : Suggestion() {
override val canDelete = true
override fun onClick() {
binding.searchview.editText.run {
setText(keyword)
setSelection(length())
}
}
}
private fun updateSuggestions() {
_binding ?: return
viewLifecycleOwner.lifecycleScope.launchIO {
mSuggestionList = mergedSuggestionFlow().toList()
}
}
private suspend fun SearchDao.suggestions(prefix: String, limit: Int) = (if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit)).map { it.query }
private fun mergedSuggestionFlow(): Flow<Suggestion> = flow {
binding.searchview.editText.text?.toString()?.let { text ->
mSuggestionProvider?.run { providerSuggestions(text)?.let { emit(it) } }
mSearchDatabase.suggestions(text, 128).forEach { emit(KeywordSuggestion(it)) }
EhTagDatabase.takeIf { it.initialized }?.run {
if (text.isNotEmpty() && !text.endsWith(' ')) {
val keyword = text.substringAfterLast(' ')
val translate = Settings.showTagTranslations && isTranslatable(requireContext())
suggestFlow(keyword, translate, true).collect {
emit(TagSuggestion(it.first, it.second))
}
suggestFlow(keyword, translate).collect {
emit(TagSuggestion(it.first, it.second))
}
}
}
}
}
fun setSuggestionProvider(suggestionProvider: SuggestionProvider) {
mSuggestionProvider = suggestionProvider
}
fun showSearchBar() {
_binding ?: return
binding.appbar.setExpanded(true)
}
private val mSearchViewOnBackPressedCallback =
object : OnBackPressedCallback(false), SearchView.TransitionListener {
override fun handleOnBackPressed() {
binding.searchview.hide()
}
override fun onStateChanged(
searchView: SearchView,
previousState: SearchView.TransitionState,
newState: SearchView.TransitionState,
) {
if (newState == SearchView.TransitionState.SHOWING) {
isEnabled = true
} else if (newState == SearchView.TransitionState.HIDING) {
isEnabled = false
}
}
}
}
fun wrapTagKeyword(keyword: String, translate: Boolean = false): String {
@@ -1,23 +0,0 @@
package com.hippo.ehviewer.ui.scene
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
interface ToolBarScene {
fun onCreateViewWithToolbar(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View
fun onNavigationClick()
fun getMenuResId(): Int
fun onMenuItemClick(item: MenuItem): Boolean
fun setLiftOnScrollTargetView(view: View?)
}
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.tools
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -24,6 +25,7 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -214,6 +216,24 @@ class DialogState {
}
}
suspend fun showSingleChoice(
items: Array<String>,
selected: Int,
): Int = showNoButton {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
items.forEachIndexed { index, text ->
Row(
modifier = Modifier.clickable { dismissWith(index) }.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = index == selected, onClick = { dismissWith(index) })
Text(text = text)
}
}
}
}
suspend fun showSelectItem(
vararg items: String?,
@StringRes title: Int,
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2022 Tarsin Norbin
~
~ This file is part of EhViewer
~
~ EhViewer is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published
~ by the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ EhViewer is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with EhViewer.
~ If not, see <https://www.gnu.org/licenses/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z" />
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M15,7v12.97l-4.21,-1.81 -0.79,-0.34 -0.79,0.34L5,19.97L5,7h10m4,-6L8.99,1C7.89,1 7,1.9 7,3h10c1.1,0 2,0.9 2,2v13l2,1L21,3c0,-1.1 -0.9,-2 -2,-2zM15,5L5,5c-1.1,0 -2,0.9 -2,2v16l7,-3 7,3L17,7c0,-1.1 -0.9,-2 -2,-2z" />
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,6h5.17l2,2L20,8v10zM13.08,14.04L12.39,17 15,15.47 17.61,17l-0.69,-2.96 2.3,-1.99 -3.03,-0.26L15,9l-1.19,2.79 -3.03,0.26z" />
</vector>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M17.63,5.84C17.27,5.33 16.67,5 16,5L5,5.01C3.9,5.01 3,5.9 3,7v10c0,1.1 0.9,1.99 2,1.99L16,19c0.67,0 1.27,-0.33 1.63,-0.84L22,12l-4.37,-6.16zM16,17H5V7h11l3.55,5L16,17z" />
</vector>
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2016 Hippo Seven
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="@string/pd_magnify" />
</vector>
@@ -28,7 +28,6 @@
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/gallery_padding_top_search_bar"
android:paddingHorizontal="@dimen/gallery_list_margin_h"
android:paddingVertical="@dimen/gallery_list_margin_v" />
@@ -37,7 +36,6 @@
android:layout_width="30dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:layout_marginTop="@dimen/gallery_padding_top_search_bar"
android:paddingStart="20dp"
android:paddingEnd="4dp"
android:paddingBottom="8dp" />
+1 -20
View File
@@ -26,8 +26,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="@dimen/gallery_search_bar_margin_v"
android:layout_marginEnd="@dimen/gallery_search_bar_margin_v"
android:paddingTop="@dimen/gallery_padding_top_search_bar" />
android:layout_marginEnd="@dimen/gallery_search_bar_margin_v" />
<com.hippo.ehviewer.ui.legacy.FabLayout
android:id="@+id/fab_layout"
@@ -60,22 +59,4 @@
</com.hippo.ehviewer.ui.legacy.FabLayout>
<com.hippo.ehviewer.ui.legacy.FabLayout
android:id="@+id/search_fab_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingEnd="@dimen/corner_fab_margin"
android:paddingBottom="@dimen/corner_fab_margin">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/search_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="0"
android:scaleY="0"
android:visibility="invisible"
app:srcCompat="@drawable/v_magnify_x24" />
</com.hippo.ehviewer.ui.legacy.FabLayout>
</merge>
@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2016 Hippo Seven
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.search.SearchBar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:navigationIcon="@drawable/ic_baseline_menu_24" />
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.search.SearchView
android:id="@+id/searchview"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_anchor="@id/toolbar"
app:useDrawerArrowDrawable="true">
<androidx.compose.ui.platform.ComposeView
android:id="@+id/search_bar_list"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.search.SearchView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -42,7 +42,6 @@
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/gallery_padding_top_search_bar"
android:clipChildren="false">
<androidx.recyclerview.widget.RecyclerView
@@ -61,7 +60,6 @@
android:layout_width="30dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:layout_marginTop="@dimen/gallery_padding_top_search_bar"
android:paddingStart="20dp"
android:paddingEnd="4dp"
android:paddingVertical="8dp" />
-50
View File
@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2016 Hippo Seven
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_open_side_sheet"
android:icon="@drawable/ic_outline_label_24"
android:title="@string/download_labels"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_filter"
android:title="@string/download_filter"
app:showAsAction="never" />
<item
android:id="@+id/action_start_all"
android:title="@string/download_start_all"
app:showAsAction="never" />
<item
android:id="@+id/action_stop_all"
android:title="@string/download_stop_all"
app:showAsAction="never" />
<item
android:id="@+id/action_reset_reading_progress"
android:title="@string/download_reset_reading_progress"
app:showAsAction="never" />
<item
android:id="@+id/action_start_all_reversed"
android:title="@string/download_start_all_reversed"
app:showAsAction="never" />
</menu>
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_open_side_sheet"
android:icon="@drawable/ic_outline_folder_special_24"
android:title="@string/collections"
app:showAsAction="ifRoom" />
</menu>
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_open_side_sheet"
android:icon="@drawable/ic_outline_bookmarks_24"
android:title="@string/quick_search"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_search_more"
android:icon="@drawable/v_plus_dark_x24"
android:title=""
app:showAsAction="always" />
</menu>
-2
View File
@@ -41,8 +41,6 @@
<dimen name="gallery_search_bar_margin_v">10dp</dimen>
<dimen name="gallery_padding_top_search_bar">80dp</dimen>
<dimen name="gallery_list_column_width_long">480dp</dimen>
<dimen name="gallery_list_column_width_short">320dp</dimen>
-1
View File
@@ -25,7 +25,6 @@
<string name="pd_download" translatable="false">M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z</string>
<string name="pd_delete" translatable="false">M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z</string>
<string name="pd_go_to" translatable="false">M13,13L17,17L21,13H18V10C18,6.1,14.9,3,11,3S4,6.1,4,10V17H6V10C6,7.2,8.2,5,11,5S16,7.2,16,10V13H13M,16,19H18V21H16V19Z</string>
<string name="pd_magnify" translatable="false">M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z</string>
<string name="pd_help_circle" translatable="false">M15.07,11.25L14.17,12.17C13.45,12.89 13,13.5 13,15H11V14.5C11,13.39 11.45,12.39 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9C14,7.89 13.1,7 12,7A2,2 0 0,0 10,9H8A4,4 0 0,1 12,5A4,4 0 0,1 16,9C16,9.88 15.64,10.67 15.07,11.25M13,19H11V17H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z</string>
<string name="pd_download_box" translatable="false">M19,3H5C3.9,3,3,3.9,3,5V19C3,20.1,3.9,21,5,21H19C20.1,21,21,20.1,21,19V5C21,3.9,20.1,3,19,3ZM12,17L7,12H10V8H14V12H17L12,17Z</string>
<string name="pd_refresh" translatable="false">M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z</string>