diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 880dbf729b..092f87690b 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -96,10 +96,6 @@ kotlin { api("androidx.camera:camera-lifecycle:${camerax_version}") api("androidx.camera:camera-view:${camerax_version}") - // LALAL REPLACE IT WITH SOURCE - // Wheel picker - api("com.github.zj565061763:compose-wheel-picker:1.0.0-alpha10") - // LALAL REMOVE api("org.jsoup:jsoup:1.13.1") api("com.godaddy.android.colorpicker:compose-color-picker-jvm:0.7.0") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/FWheelPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/FWheelPicker.kt new file mode 100644 index 0000000000..3cad946de2 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/FWheelPicker.kt @@ -0,0 +1,314 @@ +package com.sd.lib.compose.wheel_picker + +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +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.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlin.math.absoluteValue + +interface FWheelPickerContentScope { + val state: FWheelPickerState +} + +interface FWheelPickerContentWrapperScope { + val state: FWheelPickerState + + @Composable + fun content(index: Int) +} + +// LALAL: make separate repo for this + +@Composable +fun FVerticalWheelPicker( + count: Int, + state: FWheelPickerState = rememberFWheelPickerState(), + modifier: Modifier = Modifier, + key: ((index: Int) -> Any)? = null, + itemHeight: Dp = 35.dp, + unfocusedCount: Int = 1, + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + debug: Boolean = false, + focus: @Composable () -> Unit = { FWheelPickerFocusVertical() }, + contentWrapper: @Composable FWheelPickerContentWrapperScope.(index: Int) -> Unit = DefaultWheelPickerContentWrapper, + content: @Composable FWheelPickerContentScope.(index: Int) -> Unit, +) { + WheelPicker( + isVertical = true, + count = count, + state = state, + modifier = modifier, + key = key, + itemSize = itemHeight, + unfocusedCount = unfocusedCount, + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + debug = debug, + focus = focus, + contentWrapper = contentWrapper, + content = content, + ) +} + +@Composable +fun FHorizontalWheelPicker( + count: Int, + state: FWheelPickerState = rememberFWheelPickerState(), + modifier: Modifier = Modifier, + key: ((index: Int) -> Any)? = null, + itemWidth: Dp = 35.dp, + unfocusedCount: Int = 1, + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + debug: Boolean = false, + focus: @Composable () -> Unit = { FWheelPickerFocusHorizontal() }, + contentWrapper: @Composable FWheelPickerContentWrapperScope.(index: Int) -> Unit = DefaultWheelPickerContentWrapper, + content: @Composable FWheelPickerContentScope.(index: Int) -> Unit, +) { + WheelPicker( + isVertical = false, + count = count, + state = state, + modifier = modifier, + key = key, + itemSize = itemWidth, + unfocusedCount = unfocusedCount, + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + debug = debug, + focus = focus, + contentWrapper = contentWrapper, + content = content, + ) +} + +@Composable +private fun WheelPicker( + isVertical: Boolean, + count: Int, + state: FWheelPickerState, + modifier: Modifier, + key: ((index: Int) -> Any)?, + itemSize: Dp, + unfocusedCount: Int, + userScrollEnabled: Boolean, + reverseLayout: Boolean, + debug: Boolean, + focus: @Composable () -> Unit, + contentWrapper: @Composable FWheelPickerContentWrapperScope.(index: Int) -> Unit, + content: @Composable FWheelPickerContentScope.(index: Int) -> Unit, +) { + require(count >= 0) { "require count >= 0" } + require(unfocusedCount >= 1) { "require unfocusedCount >= 1" } + + state.debug = debug + LaunchedEffect(state, count) { + state.notifyCountChanged(count) + } + + val nestedScrollConnection = remember(state) { + WheelPickerNestedScrollConnection(state) + }.apply { + this.isVertical = isVertical + this.itemSizePx = with(LocalDensity.current) { itemSize.roundToPx() } + this.reverseLayout = reverseLayout + } + + val totalSize = remember(itemSize, unfocusedCount) { + itemSize * (unfocusedCount * 2 + 1) + } + + val contentWrapperScope = remember(state) { + val contentScope = WheelPickerContentScopeImpl(state) + FWheelPickerContentWrapperScopeImpl(contentScope) + }.apply { + this.content = content + } + + Box( + modifier = modifier + .nestedScroll(nestedScrollConnection) + .run { + if (totalSize > 0.dp) { + if (isVertical) { + height(totalSize).widthIn(40.dp) + } else { + width(totalSize).heightIn(40.dp) + } + } else { + this + } + }, + contentAlignment = Alignment.Center, + ) { + + val lazyListScope: LazyListScope.() -> Unit = { + repeat(unfocusedCount) { + item { + ItemSizeBox( + isVertical = isVertical, + itemSize = itemSize, + ) + } + } + + items( + count = count, + key = key, + ) { index -> + ItemSizeBox( + isVertical = isVertical, + itemSize = itemSize, + ) { + contentWrapperScope.contentWrapper(index) + } + } + + repeat(unfocusedCount) { + item { + ItemSizeBox( + isVertical = isVertical, + itemSize = itemSize, + ) + } + } + } + + if (isVertical) { + LazyColumn( + state = state.lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + reverseLayout = reverseLayout, + userScrollEnabled = userScrollEnabled, + modifier = Modifier.matchParentSize(), + content = lazyListScope, + ) + } else { + LazyRow( + state = state.lazyListState, + verticalAlignment = Alignment.CenterVertically, + reverseLayout = reverseLayout, + userScrollEnabled = userScrollEnabled, + modifier = Modifier.matchParentSize(), + content = lazyListScope, + ) + } + + ItemSizeBox( + modifier = Modifier.align(Alignment.Center), + isVertical = isVertical, + itemSize = itemSize, + ) { + focus() + } + } +} + +@Composable +private fun ItemSizeBox( + modifier: Modifier = Modifier, + isVertical: Boolean, + itemSize: Dp, + content: @Composable () -> Unit = { }, +) { + Box( + modifier + .run { + if (isVertical) { + height(itemSize) + } else { + width(itemSize) + } + }, + contentAlignment = Alignment.Center, + ) { + content() + } +} + +private class WheelPickerNestedScrollConnection( + private val state: FWheelPickerState, +) : NestedScrollConnection { + var isVertical: Boolean? = null + var itemSizePx: Int? = null + var reverseLayout: Boolean? = null + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + state.synchronizeCurrentIndexSnapshot() + return super.onPostScroll(consumed, available, source) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val currentIndex = state.synchronizeCurrentIndexSnapshot() + return if (currentIndex >= 0) { + val flingItemCount = available.flingItemCount( + isVertical = isVertical!!, + itemSize = itemSizePx!!, + decay = exponentialDecay(2f), + reverseLayout = reverseLayout!!, + ) + + if (flingItemCount.absoluteValue > 0) { + state.animateScrollToIndex(currentIndex - flingItemCount) + } else { + state.animateScrollToIndex(currentIndex) + } + available + } else { + super.onPreFling(available) + } + } +} + +private fun Velocity.flingItemCount( + isVertical: Boolean, + itemSize: Int, + decay: DecayAnimationSpec, + reverseLayout: Boolean, +): Int { + if (itemSize <= 0) return 0 + val velocity = if (isVertical) y else x + val targetValue = decay.calculateTargetValue(0f, velocity) + val flingItemCount = (targetValue / itemSize).toInt() + return if (reverseLayout) -flingItemCount else flingItemCount +} + +private class WheelPickerContentScopeImpl( + override val state: FWheelPickerState, +) : FWheelPickerContentScope + +private class FWheelPickerContentWrapperScopeImpl( + private val contentScope: FWheelPickerContentScope +) : FWheelPickerContentWrapperScope { + lateinit var content: @Composable FWheelPickerContentScope.(index: Int) -> Unit + + override val state: FWheelPickerState get() = contentScope.state + + @Composable + override fun content(index: Int) { + contentScope.content(index) + } +} + +internal inline fun logMsg(debug: Boolean, block: () -> String) { + if (debug) { + println("FWheelPicker" + block()) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/FWheelPickerDefault.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/FWheelPickerDefault.kt new file mode 100644 index 0000000000..ed2533b488 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/FWheelPickerDefault.kt @@ -0,0 +1,104 @@ +package com.sd.lib.compose.wheel_picker + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * The default implementation of focus view in vertical. + */ +@Composable +fun FWheelPickerFocusVertical( + modifier: Modifier = Modifier, + dividerSize: Dp = 1.dp, + dividerColor: Color = DefaultDividerColor, +) { + Box( + modifier = modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .background(dividerColor) + .height(dividerSize) + .fillMaxWidth() + .align(Alignment.TopCenter), + ) + Box( + modifier = Modifier + .background(dividerColor) + .height(dividerSize) + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + } +} + +/** + * The default implementation of focus view in horizontal. + */ +@Composable +fun FWheelPickerFocusHorizontal( + modifier: Modifier = Modifier, + dividerSize: Dp = 1.dp, + dividerColor: Color = DefaultDividerColor, +) { + Box( + modifier = modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .background(dividerColor) + .width(dividerSize) + .fillMaxHeight() + .align(Alignment.CenterStart), + ) + Box( + modifier = Modifier + .background(dividerColor) + .width(dividerSize) + .fillMaxHeight() + .align(Alignment.CenterEnd), + ) + } +} + +/** + * Default divider color. + */ +private val DefaultDividerColor: Color + @Composable + get() = (if (isSystemInDarkTheme()) { + Color.White + } else { + Color.Black + }).copy(alpha = 0.2f) + +/** + * Default content wrapper. + */ +val DefaultWheelPickerContentWrapper: @Composable FWheelPickerContentWrapperScope.(index: Int) -> Unit + get() = { index -> + val isFocus = index == state.currentIndexSnapshot + val targetAlpha = if (isFocus) 1.0f else 0.3f + val targetScale = if (isFocus) 1.0f else 0.8f + val animateScale by animateFloatAsState(targetScale) + Box( + modifier = Modifier + .graphicsLayer { + this.alpha = targetAlpha + this.scaleX = animateScale + this.scaleY = animateScale + } + ) { + content(index) + } + } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/FWheelPickerState.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/FWheelPickerState.kt new file mode 100644 index 0000000000..154a91b705 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/FWheelPickerState.kt @@ -0,0 +1,223 @@ +package com.sd.lib.compose.wheel_picker + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.math.absoluteValue + +@Composable +fun rememberFWheelPickerState( + initialIndex: Int = 0, +): FWheelPickerState = rememberSaveable(saver = FWheelPickerState.Saver) { + FWheelPickerState( + initialIndex = initialIndex, + ) +} + +class FWheelPickerState( + initialIndex: Int = 0, +) : ScrollableState { + + internal var debug = false + internal val lazyListState = LazyListState() + + private var _currentIndex by mutableStateOf(-1) + private var _currentIndexSnapshot by mutableStateOf(-1) + + private var _pendingIndex: Int? = initialIndex + set(value) { + field = value + if (value == null) resumeAwaitScroll() + } + private var _pendingIndexContinuation: Continuation? = null + + private var _count = 0 + set(value) { + field = value.coerceAtLeast(0) + } + + /** + * Index of picker when it is idle. + * + * Note that this property is observable and if you use it in the composable function + * it will be recomposed on every change. + */ + val currentIndex: Int + get() = _currentIndex + + /** + * Index of picker when it is idle or drag but not fling. + * + * Note that this property is observable and if you use it in the composable function + * it will be recomposed on every change. + */ + val currentIndexSnapshot: Int + get() = _currentIndexSnapshot + + val interactionSource: InteractionSource + get() = lazyListState.interactionSource + + val hasPendingScroll: Boolean + get() = _pendingIndex != null + + suspend fun animateScrollToIndex( + index: Int, + ) { + logMsg(debug) { "animateScrollToIndex index:$index" } + + val safeIndex = index.coerceAtLeast(0) + lazyListState.animateScrollToItem(safeIndex) + synchronizeCurrentIndex() + } + + suspend fun scrollToIndex( + index: Int, + pending: Boolean = true, + ) { + logMsg(debug) { "scrollToIndex index:$index pending:$pending" } + + val safeIndex = index.coerceAtLeast(0) + lazyListState.scrollToItem(safeIndex) + synchronizeCurrentIndex() + + if (pending) { + awaitScroll(safeIndex) + } + } + + private suspend fun awaitScroll(index: Int) { + if (_currentIndex == index) return + logMsg(debug) { "awaitScroll index $index start" } + + // Resume last continuation before suspend. + resumeAwaitScroll() + + _pendingIndex = index + suspendCancellableCoroutine { + _pendingIndexContinuation = it + it.invokeOnCancellation { + logMsg(debug) { "awaitScroll index $index canceled" } + _pendingIndexContinuation = null + _pendingIndex = null + + } + } + + logMsg(debug) { "awaitScroll index $index finish" } + } + + private fun resumeAwaitScroll() { + _pendingIndexContinuation?.let { + logMsg(debug) { "resumeAwaitScroll pendingIndex:$_pendingIndex" } + it.resume(Unit) + _pendingIndexContinuation = null + } + } + + internal suspend fun notifyCountChanged(count: Int) { + logMsg(debug) { "notifyCountChanged count:$count currentIndex:$_currentIndex pendingIndex:$_pendingIndex" } + + _count = count + + val maxIndex = count - 1 + if (_currentIndex > maxIndex) { + setCurrentIndexInternal(maxIndex) + } else { + _pendingIndex?.let { pendingIndex -> + if (count > pendingIndex) { + scrollToIndex(pendingIndex, pending = false) + } + } + if (_currentIndex < 0) { + synchronizeCurrentIndex() + } + } + } + + private fun synchronizeCurrentIndex() { + logMsg(debug) { "synchronizeCurrentIndex" } + val index = synchronizeCurrentIndexSnapshot() + setCurrentIndexInternal(index) + } + + private fun setCurrentIndexInternal(index: Int) { + val safeIndex = index.coerceAtLeast(-1) + if (_currentIndex != safeIndex) { + logMsg(debug) { "Current index changed $safeIndex" } + _currentIndex = safeIndex + _currentIndexSnapshot = safeIndex + if (_pendingIndex == safeIndex) { + _pendingIndex = null + } + } + } + + internal fun synchronizeCurrentIndexSnapshot(): Int { + return (mostStartItemInfo()?.index ?: -1).also { + _currentIndexSnapshot = it + } + } + + /** + * The item closest to the viewport start. + */ + private fun mostStartItemInfo(): LazyListItemInfo? { + if (_count <= 0) return null + + val layoutInfo = lazyListState.layoutInfo + val listInfo = layoutInfo.visibleItemsInfo + + if (listInfo.isEmpty()) return null + if (listInfo.size == 1) return listInfo.first() + + val firstItem = listInfo.first() + val firstOffsetDelta = (firstItem.offset - layoutInfo.viewportStartOffset).absoluteValue + return if (firstOffsetDelta < firstItem.size / 2) { + firstItem + } else { + listInfo[1] + } + } + + override val isScrollInProgress: Boolean + get() = lazyListState.isScrollInProgress + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit, + ) { + lazyListState.scroll(scrollPriority, block) + } + + override fun dispatchRawDelta(delta: Float): Float { + return lazyListState.dispatchRawDelta(delta) + } + + companion object { + val Saver: Saver = listSaver( + save = { + listOf( + it.currentIndex, + ) + }, + restore = { + FWheelPickerState( + initialIndex = it[0] as Int, + ) + } + ) + } +}