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:
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.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* 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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user