FloatingSearchBar

This commit is contained in:
tom5079
2021-12-15 16:30:05 +09:00
parent 458530e80c
commit b690d01243
9 changed files with 292 additions and 99 deletions

View File

@@ -152,7 +152,7 @@ class Hitomi(app: Application) : Source(), DIAware {
var cachedSortMode: Int = -1
private val cache = mutableListOf<Int>()
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> {
override suspend fun search(query: String, range: IntRange, sortMode: Int): Pair<Channel<ItemInfo>, Int> = coroutineScope { withContext(Dispatchers.IO) {
if (cachedQuery != query || cachedSortMode != sortMode || cache.isEmpty()) {
cachedQuery = null
cache.clear()
@@ -179,8 +179,8 @@ class Hitomi(app: Application) : Source(), DIAware {
channel.close()
}
return Pair(channel, cache.size)
}
Pair(channel, cache.size)
} }
override suspend fun suggestion(query: String) : List<TagSuggestion> {
return getSuggestionsForQuery(query.takeLastWhile { !it.isWhitespace() }).map {

View File

@@ -23,22 +23,33 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.gestures.scrollable
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.Scaffold
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerInteropFilter
@@ -47,6 +58,10 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.WindowCompat
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -68,6 +83,11 @@ import xyz.quaver.pupil.ui.viewmodel.MainViewModel
import xyz.quaver.pupil.util.*
import kotlin.math.*
private enum class NavigationIconState {
MENU,
ARROW
}
class MainActivity : ComponentActivity(), DIAware {
override val di by closestDI()
@@ -75,37 +95,36 @@ class MainActivity : ComponentActivity(), DIAware {
private val logger = newLogger(LoggerFactory.default)
@OptIn(ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val source: Source? by model.source.observeAsState(null)
val loading: Boolean by model.loading.observeAsState(false)
var query by remember { mutableStateOf("") }
var isFabExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) }
val lazyListState = rememberLazyListState()
val searchBarHeight = LocalDensity.current.run { 56.dp.roundToPx() }
var searchBarOffset by remember { mutableStateOf(0) }
LaunchedEffect(lazyListState) {
var lastOffset = 0
snapshotFlow { lazyListState.firstVisibleItemScrollOffset }
.distinctUntilChanged()
.collect { newOffset ->
val dy = newOffset - lastOffset
lastOffset = newOffset
if (abs(dy) < searchBarHeight)
searchBarOffset = (searchBarOffset-dy).coerceIn(-searchBarHeight, 0)
}
}
PupilTheme {
val source: Source? by model.source.observeAsState(null)
var isFabExpanded by remember { mutableStateOf(FloatingActionButtonState.COLLAPSED) }
var isFabVisible by remember { mutableStateOf(true) }
val searchBarHeight = LocalDensity.current.run { 56.dp.roundToPx() }
var searchBarOffset by remember { mutableStateOf(0) }
val navigationIcon = remember { DrawerArrowDrawable(this) }
var navigationIconState by remember { mutableStateOf(NavigationIconState.MENU) }
val navigationIconTransition = updateTransition(navigationIconState, label = "navigationIconTransition")
val navigationIconProgress by navigationIconTransition.animateFloat(
label = "navigationIconProgress"
) { state ->
when (state) {
NavigationIconState.MENU -> 0f
NavigationIconState.ARROW -> 1f
}
}
LaunchedEffect(navigationIconProgress) {
navigationIcon.progress = navigationIconProgress
}
Scaffold(
floatingActionButton = {
MultipleFloatingActionButton(
@@ -127,6 +146,7 @@ class MainActivity : ComponentActivity(), DIAware {
stringResource(R.string.main_open_gallery_by_id)
),
),
visible = isFabVisible,
targetState = isFabExpanded,
onStateChanged = {
isFabExpanded = it
@@ -136,8 +156,24 @@ class MainActivity : ComponentActivity(), DIAware {
) {
Box(Modifier.fillMaxSize()) {
LazyColumn(
Modifier.fillMaxSize(),
state = lazyListState,
Modifier
.fillMaxSize()
.nestedScroll(object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
searchBarOffset =
(searchBarOffset + available.y.roundToInt()).coerceIn(
-searchBarHeight,
0
)
isFabVisible = available.y > 0f
return Offset.Zero
}
}),
contentPadding = PaddingValues(0.dp, 56.dp, 0.dp, 0.dp)
) {
items(model.searchResults, key = { it.itemID }) { itemInfo ->
@@ -159,25 +195,36 @@ class MainActivity : ComponentActivity(), DIAware {
}
}
if (loading)
if (model.loading)
CircularProgressIndicator(Modifier.align(Alignment.Center))
FloatingSearchBar(
modifier = Modifier.offset(0.dp, LocalDensity.current.run { searchBarOffset.toDp() }),
query = query,
onQueryChange = { query = it },
query = model.query,
onQueryChange = { model.query = it },
navigationIcon = {
Icon(
painter = rememberDrawablePainter(navigationIcon),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
},
actions = {
Icon(
Icons.Default.Sort,
contentDescription = null,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)
)
Icon(
Icons.Default.Settings,
contentDescription = null,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)
)
}
},
onTextFieldFocused = { navigationIconState = NavigationIconState.ARROW },
onTextFieldUnfocused = { navigationIconState = NavigationIconState.MENU; model.resetAndQuery() }
)
}
}

View File

@@ -19,23 +19,31 @@
package xyz.quaver.pupil.ui.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.runtime.Composable
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.KeyboardManager
@Preview
@Composable
@@ -43,16 +51,26 @@ fun FloatingSearchBar(
modifier: Modifier = Modifier,
query: String = "",
onQueryChange: (String) -> Unit = { },
navigationIcon: @Composable () -> Unit = {
Icon(
Icons.Default.Menu,
modifier = Modifier.size(24.dp),
contentDescription = null,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)
)
},
actions: @Composable RowScope.() -> Unit = { }
navigationIcon: @Composable () -> Unit = { },
actions: @Composable RowScope.() -> Unit = { },
onTextFieldFocused: () -> Unit = { },
onTextFieldUnfocused: () -> Unit = { }
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
var isFocused by remember { mutableStateOf(false) }
DisposableEffect(context) {
val keyboardManager = KeyboardManager(context)
keyboardManager.attachKeyboardDismissListener {
focusManager.clearFocus()
}
onDispose {
keyboardManager.release()
}
}
Card(
modifier = modifier
.fillMaxWidth()
@@ -68,19 +86,46 @@ fun FloatingSearchBar(
navigationIcon()
BasicTextField(
modifier = Modifier.weight(1f).padding(16.dp, 0.dp),
modifier = Modifier
.weight(1f)
.padding(16.dp, 0.dp)
.onFocusChanged {
if (it.isFocused) onTextFieldFocused()
else onTextFieldUnfocused()
isFocused = it.isFocused
},
value = query,
onValueChange = onQueryChange,
singleLine = true,
cursorBrush = SolidColor(MaterialTheme.colors.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
focusManager.clearFocus()
}
),
decorationBox = { innerTextField ->
if (query.isEmpty())
Text(
stringResource(R.string.search_hint),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f)) {
if (query.isEmpty())
Text(
stringResource(R.string.search_hint),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)
)
innerTextField()
}
innerTextField()
if (query.isNotEmpty() && isFocused)
Icon(
Icons.Default.Close,
contentDescription = null,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
modifier = Modifier.clickable { onQueryChange("") }
)
}
}
)

View File

@@ -16,6 +16,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier.Companion.any
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
@@ -28,6 +29,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAll
enum class FloatingActionButtonState(private val isExpanded: Boolean) {
COLLAPSED(false), EXPANDED(true);
@@ -112,6 +114,7 @@ private class FloatingActionButtonItemProvider : PreviewParameterProvider<SubFab
fun MultipleFloatingActionButton(
@PreviewParameter(provider = FloatingActionButtonItemProvider::class) items: List<SubFabItem>,
fabIcon: ImageVector = Icons.Default.Add,
visible: Boolean = true,
targetState: FloatingActionButtonState = FloatingActionButtonState.COLLAPSED,
onStateChanged: ((FloatingActionButtonState) -> Unit)? = null
) {
@@ -131,10 +134,14 @@ fun MultipleFloatingActionButton(
}
}
if (!visible) onStateChanged?.invoke(FloatingActionButtonState.COLLAPSED)
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
var allCollapsed = true
items.forEachIndexed { index, item ->
val delay = when (targetState) {
FloatingActionButtonState.COLLAPSED -> index
@@ -207,12 +214,26 @@ fun MultipleFloatingActionButton(
) {
item.onClick?.invoke(it)
}
if (buttonScale != 0f) allCollapsed = false
}
FloatingActionButton(onClick = {
onStateChanged?.invoke(!targetState)
}) {
Icon(modifier = Modifier.rotate(rotation), imageVector = fabIcon, contentDescription = null)
val visibilityTransition = updateTransition(targetState = visible || !allCollapsed, label = "visible")
val scale by visibilityTransition.animateFloat(
label = "main FAB scale"
) { state ->
if (state) 1f else 0f
}
if (scale > 0f)
FloatingActionButton(
modifier = Modifier.scale(scale),
onClick = {
onStateChanged?.invoke(!targetState)
}
) {
Icon(modifier = Modifier.rotate(rotation), imageVector = fabIcon, contentDescription = null)
}
}
}

View File

@@ -20,13 +20,18 @@ package xyz.quaver.pupil.ui.viewmodel
import android.annotation.SuppressLint
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import kotlinx.coroutines.*
import org.kodein.di.DIAware
import org.kodein.di.android.x.closestDI
import org.kodein.di.direct
import org.kodein.di.instance
import org.kodein.log.LoggerFactory
import org.kodein.log.newLogger
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.sources.*
import xyz.quaver.pupil.util.Preferences
@@ -39,15 +44,17 @@ import kotlin.random.Random
class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
override val di by closestDI()
private val logger = newLogger(LoggerFactory.default)
val searchResults = mutableStateListOf<ItemInfo>()
private val _loading = MutableLiveData(false)
val loading = _loading as LiveData<Boolean>
var loading by mutableStateOf(false)
private set
private var queryJob: Job? = null
private var suggestionJob: Job? = null
val query = MutableLiveData<String>()
var query by mutableStateOf("")
private val queryStack = mutableListOf<String>()
private val defaultSourceFactory: (String) -> Source = {
@@ -90,11 +97,11 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
sortModeIndex.value = 0
}
setQueryAndSearch()
query = ""
resetAndQuery()
}
fun setQueryAndSearch(query: String = "") {
this.query.value = query
fun resetAndQuery() {
queryStack.add(query)
setPage(1)
@@ -126,29 +133,26 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
suggestionJob?.cancel()
queryJob?.cancel()
_loading.value = true
loading = true
searchResults.clear()
queryJob = viewModelScope.launch {
launch(Dispatchers.Default) {
val channel = withContext(Dispatchers.IO) {
val (channel, count) = source.search(
query.value ?: "",
(currentPage - 1) * perPage until currentPage * perPage,
sortModeIndex
)
val (channel, count) = source.search(
query,
(currentPage - 1) * perPage until currentPage * perPage,
sortModeIndex
)
totalItems.postValue(count)
logger.info { count.toString() }
channel
}
totalItems.postValue(count)
for (result in channel) {
yield()
searchResults.add(result)
}
_loading.postValue(false)
for (result in channel) {
yield()
searchResults.add(result)
}
loading = false
}
}
@@ -165,7 +169,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
viewModelScope.launch {
withContext(Dispatchers.IO) {
_source.value?.search(
query.value + Preferences["default_query", ""],
query + Preferences["default_query", ""],
random .. random,
sortModeIndex.value!!
)?.first?.receive()
@@ -182,7 +186,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
@SuppressLint("NullSafeMutableLiveData")
_suggestions.value = withContext(Dispatchers.IO) {
kotlin.runCatching {
_source.value!!.suggestion(query.value!!)
_source.value!!.suggestion(query)
}.getOrElse { emptyList() }
}
}
@@ -195,7 +199,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app), DIAware {
if (queryStack.removeLastOrNull() == null || queryStack.isEmpty())
return false
setQueryAndSearch(queryStack.removeLast())
query = queryStack.removeLast()
resetAndQuery()
return true
}

View File

@@ -0,0 +1,67 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program 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.
*
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import android.app.Activity
import android.content.Context
import android.graphics.Rect
import android.view.View
import android.view.ViewTreeObserver
//https://stackoverflow.com/questions/68389802/how-to-clear-textfield-focus-when-closing-the-keyboard-and-prevent-two-back-pres
class KeyboardManager(context: Context) {
private val activity = context as Activity
private var keyboardDismissListener: KeyboardDismissListener? = null
private abstract class KeyboardDismissListener(
private val rootView: View,
private val onKeyboardDismiss: () -> Unit
) : ViewTreeObserver.OnGlobalLayoutListener {
private var isKeyboardClosed: Boolean = false
override fun onGlobalLayout() {
val r = Rect()
rootView.getWindowVisibleDisplayFrame(r)
val screenHeight = rootView.rootView.height
val keypadHeight = screenHeight - r.bottom
if (keypadHeight > screenHeight * 0.15) {
// 0.15 ratio is right enough to determine keypad height.
isKeyboardClosed = false
} else if (!isKeyboardClosed) {
isKeyboardClosed = true
onKeyboardDismiss.invoke()
}
}
}
fun attachKeyboardDismissListener(onKeyboardDismiss: () -> Unit) {
val rootView = activity.findViewById<View>(android.R.id.content)
keyboardDismissListener = object : KeyboardDismissListener(rootView, onKeyboardDismiss) {}
keyboardDismissListener?.let {
rootView.viewTreeObserver.addOnGlobalLayoutListener(it)
}
}
fun release() {
val rootView = activity.findViewById<View>(android.R.id.content)
keyboardDismissListener?.let {
rootView.viewTreeObserver.removeOnGlobalLayoutListener(it)
}
keyboardDismissListener = null
}
}

View File

@@ -25,14 +25,18 @@ import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.view.MenuItem
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toAndroidRect
import androidx.compose.ui.platform.LocalFocusManager
import androidx.lifecycle.MutableLiveData
import com.google.accompanist.insets.LocalWindowInsets
import kotlinx.serialization.json.*
import org.kodein.di.DIAware
import org.kodein.di.DirectDIAware