diff --git a/app-scaffold/build.gradle.kts b/app-scaffold/build.gradle.kts index 1def8f8b6..5fd6e85bf 100755 --- a/app-scaffold/build.gradle.kts +++ b/app-scaffold/build.gradle.kts @@ -190,6 +190,7 @@ dependencies { implementation(libs.sqldelight.paging) implementation(libs.sqldelight.primitiveAdapters) implementation(libs.sqldelight.runtime) + implementation(libs.telephoto.flick) implementation(libs.telephoto.zoomable) implementation(libs.telephoto.zoomableImage) implementation(libs.telephoto.zoomableImageCoil) diff --git a/app-scaffold/src/main/kotlin/catchup/app/service/ServiceScreen.kt b/app-scaffold/src/main/kotlin/catchup/app/service/ServiceScreen.kt index 10466fde9..84334c153 100644 --- a/app-scaffold/src/main/kotlin/catchup/app/service/ServiceScreen.kt +++ b/app-scaffold/src/main/kotlin/catchup/app/service/ServiceScreen.kt @@ -31,6 +31,7 @@ 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.graphics.toArgb import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -116,7 +117,7 @@ data class ServiceScreen(val serviceKey: String) : Screen { } sealed interface Event : CircuitUiEvent { - data class ItemClicked(val item: CatchUpItem) : Event + data class ItemClicked(val item: CatchUpItem, val colorHint: Color = Color.Unspecified) : Event data class ItemActionClicked(val item: CatchUpItem, val action: Action) : Event { enum class Action { @@ -171,6 +172,7 @@ constructor( isBitmap = !info.animatable, info.cacheKey, info.sourceUrl, + event.colorHint.toArgb(), ) ) } else { @@ -190,6 +192,7 @@ constructor( isBitmap = bestGuessIsBitmap, alias = null, sourceUrl = url, + backgroundColor = event.colorHint.toArgb(), ) ) } else { diff --git a/app-scaffold/src/main/kotlin/catchup/app/service/VisualServiceUi.kt b/app-scaffold/src/main/kotlin/catchup/app/service/VisualServiceUi.kt index 272f884e8..d8bafe04f 100644 --- a/app-scaffold/src/main/kotlin/catchup/app/service/VisualServiceUi.kt +++ b/app-scaffold/src/main/kotlin/catchup/app/service/VisualServiceUi.kt @@ -104,7 +104,12 @@ fun VisualServiceUi( item.imageInfo?.color?.let { Color(android.graphics.Color.parseColor(it)) } ?: Color.Unspecified ) - ClickableItem(onClick = { eventSink(ItemClicked(item)) }, state = clickableItemState) { + ClickableItem( + onClick = { + eventSink(ServiceScreen.Event.ItemClicked(item, clickableItemState.contentColor)) + }, + state = clickableItemState, + ) { VisualItem( item = item, index = index, diff --git a/app-scaffold/src/main/kotlin/catchup/app/ui/activity/FlickToDismiss.kt b/app-scaffold/src/main/kotlin/catchup/app/ui/activity/FlickToDismiss.kt deleted file mode 100644 index 88fa19387..000000000 --- a/app-scaffold/src/main/kotlin/catchup/app/ui/activity/FlickToDismiss.kt +++ /dev/null @@ -1,159 +0,0 @@ -// Adapted with permission from https://gist.github.com/saket/94ddc78c4d96902ec3be71e916a03d06 -package catchup.app.ui.activity - -import androidx.annotation.FloatRange -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationConstants -import androidx.compose.animation.core.tween -import androidx.compose.foundation.MutatePriority -import androidx.compose.foundation.gestures.DraggableState -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import catchup.app.ui.activity.FlickToDismissState.FlickGestureState.Dismissed -import catchup.app.ui.activity.FlickToDismissState.FlickGestureState.Dragging -import catchup.app.ui.activity.FlickToDismissState.FlickGestureState.Idle -import kotlin.math.abs - -@Composable -fun FlickToDismiss( - modifier: Modifier = Modifier, - state: FlickToDismissState = rememberFlickToDismissState(), - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - content: @Composable BoxScope.() -> Unit, -) { - val dragStartedOnLeftSide = remember { mutableStateOf(false) } - - Box( - modifier = - modifier - .offset { IntOffset(x = 0, y = state.offset.toInt()) } - .graphicsLayer { - rotationZ = state.offsetRatio * if (dragStartedOnLeftSide.value) -20F else 20F - } - .draggable( - enabled = state.enabled, - state = state.draggableState, - orientation = Orientation.Vertical, - interactionSource = interactionSource, - startDragImmediately = state.isResettingOnRelease, - onDragStarted = { startedPosition -> - dragStartedOnLeftSide.value = startedPosition.x < (state.contentSize.value!!.width / 2f) - }, - onDragStopped = { - if (state.willDismissOnRelease) { - state.animateDismissal() - state.gestureState = Dismissed - } else { - state.resetOffset() - } - }, - ) - .onGloballyPositioned { coordinates -> state.contentSize.value = coordinates.size }, - content = content, - ) -} - -@Composable -fun rememberFlickToDismissState(): FlickToDismissState { - return remember { FlickToDismissState() } -} - -/** - * @param dismissThresholdRatio Minimum distance the user's finger should move as a ratio to the - * content's dimensions after which it can be dismissed. - */ -@Stable -data class FlickToDismissState( - val enabled: Boolean = true, - val dismissThresholdRatio: Float = 0.15f, - val rotateOnDrag: Boolean = true, -) { - val offset: Float - get() = offsetState.value - - val offsetState = mutableStateOf(0f) - - /** Distance dragged as a ratio of the content's height. */ - @get:FloatRange(from = -1.0, to = 1.0) - val offsetRatio: Float by derivedStateOf { - val contentHeight = contentSize.value?.height - if (contentHeight == null) { - 0f - } else { - offset / contentHeight.toFloat() - } - } - - var isResettingOnRelease: Boolean by mutableStateOf(false) - private set - - var gestureState: FlickGestureState by mutableStateOf(Idle) - internal set - - val willDismissOnRelease: Boolean by derivedStateOf { - when (gestureState) { - is Dismissed -> true - is Dragging, - is Idle -> abs(offsetRatio) > dismissThresholdRatio - } - } - - internal var contentSize = mutableStateOf(null as IntSize?) - - internal val draggableState = DraggableState { dy -> - offsetState.value += dy - - gestureState = - when { - gestureState is Dismissed -> gestureState - offset == 0f -> Idle - else -> Dragging - } - } - - internal suspend fun resetOffset() { - draggableState.drag(MutatePriority.PreventUserInput) { - isResettingOnRelease = true - try { - Animatable(offset).animateTo(targetValue = 0f) { dragBy(value - offset) } - } finally { - isResettingOnRelease = false - } - } - } - - internal suspend fun animateDismissal() { - draggableState.drag(MutatePriority.PreventUserInput) { - Animatable(offset).animateTo( - targetValue = contentSize.value!!.height * if (offset > 0f) 1f else -1f, - animationSpec = tween(AnimationConstants.DefaultDurationMillis), - ) { - dragBy(value - offset) - } - } - } - - sealed interface FlickGestureState { - object Idle : FlickGestureState - - object Dragging : FlickGestureState - - object Dismissed : FlickGestureState - } -} diff --git a/app-scaffold/src/main/kotlin/catchup/app/ui/activity/ImageViewerScreen.kt b/app-scaffold/src/main/kotlin/catchup/app/ui/activity/ImageViewerScreen.kt index dbc004be2..27b5b47cb 100644 --- a/app-scaffold/src/main/kotlin/catchup/app/ui/activity/ImageViewerScreen.kt +++ b/app-scaffold/src/main/kotlin/catchup/app/ui/activity/ImageViewerScreen.kt @@ -1,36 +1,45 @@ package catchup.app.ui.activity +import androidx.annotation.ColorInt import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.Surface +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex import androidx.core.view.WindowInsetsControllerCompat import catchup.app.data.LinkManager import catchup.app.service.openUrl -import catchup.app.ui.activity.FlickToDismissState.FlickGestureState.Dismissed import catchup.app.ui.activity.ImageViewerScreen.Event import catchup.app.ui.activity.ImageViewerScreen.Event.Close import catchup.app.ui.activity.ImageViewerScreen.Event.CopyImage @@ -64,8 +73,14 @@ import dagger.assisted.AssistedInject import dev.zacsweers.catchup.app.scaffold.R import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import me.saket.telephoto.flick.FlickToDismiss +import me.saket.telephoto.flick.FlickToDismissState +import me.saket.telephoto.flick.FlickToDismissState.GestureState +import me.saket.telephoto.flick.FlickToDismissState.GestureState.Dismissing +import me.saket.telephoto.flick.rememberFlickToDismissState import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState @@ -78,6 +93,7 @@ data class ImageViewerScreen( val isBitmap: Boolean, val alias: String?, val sourceUrl: String, + @ColorInt val backgroundColor: Int = Color.Unspecified.toArgb(), ) : Screen { data class State( val id: String, @@ -85,6 +101,7 @@ data class ImageViewerScreen( val alias: String?, val sourceUrl: String, val isBitmap: Boolean, + val backgroundColor: Color, val eventSink: (Event) -> Unit, ) : CircuitUiState @@ -126,6 +143,7 @@ constructor( alias = screen.alias, isBitmap = screen.isBitmap, sourceUrl = screen.sourceUrl, + backgroundColor = Color(screen.backgroundColor), ) { event -> // TODO finish implementing these. Also why is copying an image on android so terrible in // 2023. @@ -155,35 +173,79 @@ fun ImageViewer(state: State, modifier: Modifier = Modifier) { // Set BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE so the UI doesn't jump when it hides systemUiController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + val originalIsDarkContent = systemUiController.systemBarsDarkContentEnabled + systemUiController.systemBarsDarkContentEnabled = false onDispose { - // TODO this is too late for some reason systemUiController.isSystemBarsVisible = true systemUiController.systemBarsBehavior = originalSystemBarsBehavior + systemUiController.systemBarsDarkContentEnabled = originalIsDarkContent } } CatchUpTheme(useDarkTheme = true) { - val backgroundAlpha: Float by - animateFloatAsState(targetValue = 1f, animationSpec = tween(), label = "backgroundAlpha") - Surface( - modifier.fillMaxSize().animateContentSize(), - color = Color.Black.copy(alpha = backgroundAlpha), + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + containerColor = Color.Transparent, contentColor = Color.White, - ) { - Box(Modifier.fillMaxSize()) { + ) { contentPadding -> + val flickState = rememberFlickToDismissState(rotateOnDrag = false) + val backgroundColor = + if (state.backgroundColor == Color.Unspecified) { + MaterialTheme.colorScheme.background + } else { + state.backgroundColor + } + Box( + modifier + .padding(contentPadding) + .fillMaxSize() + .background(backgroundColor.copy(alpha = 1f - flickState.offsetFraction)) + ) { // Image + scrim - - val dismissState = rememberFlickToDismissState() - if (dismissState.gestureState is Dismissed) { - state.eventSink(Close) + CloseScreenOnFlickDismissEffect(flickState) { + state.eventSink(ImageViewerScreen.Event.Close) } - // TODO bind scrim with flick. animate scrim out after flick finishes? Or with flick? - FlickToDismiss(state = dismissState) { + + // TODO + // corner shape on drag offset + // dropshadow on dragging + FlickToDismiss(state = flickState) { val overlayHost = LocalOverlayHost.current val scope = rememberStableCoroutineScope() val zoomableState = rememberZoomableState(ZoomSpec(maxZoomFactor = 2f)) val imageState = rememberZoomableImageState(zoomableState) // TODO loading loading indicator if there's no memory cached alias + + // TODO elevation and corner radius don't actually work currently + val targetElevation = + if (flickState.gestureState is GestureState.Dragging) { + 32.dp + } else { + 0.dp + } + val targetRadius = + if (flickState.gestureState is GestureState.Dragging) { + 32.dp + } else { + 0.dp + } + + val elevation by animateDpAsState(targetElevation, label = "Animated elevation") + val cornerRadius by animateDpAsState(targetRadius, label = "Animated corner radius") + + // Scale x/y based on the flick offset + // We scale at most to 0.95f, and lerp this within the first 30% of the offset fraction + val scale by remember { + derivedStateOf { + val adjustedFraction = + when { + flickState.offsetFraction <= 0f -> 0f + flickState.offsetFraction >= 0.3f -> 1f + else -> flickState.offsetFraction / 0.3f + } + lerp(1f, 0.95f, adjustedFraction) + } + } ZoomableAsyncImage( model = Builder(LocalContext.current) @@ -191,15 +253,24 @@ fun ImageViewer(state: State, modifier: Modifier = Modifier) { .apply { state.alias?.let(::placeholderMemoryCacheKey) } .build(), contentDescription = "TODO", - modifier = Modifier.fillMaxSize(), + modifier = + Modifier.fillMaxSize() + .graphicsLayer(scaleX = scale, scaleY = scale) + .zIndex(elevation.value) + .clip(RoundedCornerShape(cornerRadius)), state = imageState, onClick = { showChrome = !showChrome }, onLongClick = { launchShareSheet(scope, overlayHost, state) }, ) } - // TODO pick color based on if image is underneath it or not. Similar to badges - AnimatedVisibility(showChrome, enter = fadeIn(), exit = fadeOut()) { + // TODO pick color based on if image is underneath it or not. Similar to badges? + // Alternatively make this just a very small button? + AnimatedVisibility( + showChrome && flickState.gestureState == GestureState.Idle, + enter = fadeIn(), + exit = fadeOut(), + ) { NavButton(Modifier.align(Alignment.TopStart).padding(16.dp).statusBarsPadding(), CLOSE) { state.eventSink(Close) } @@ -209,6 +280,21 @@ fun ImageViewer(state: State, modifier: Modifier = Modifier) { } } +@Composable +private fun CloseScreenOnFlickDismissEffect( + flickState: FlickToDismissState, + onDismiss: () -> Unit, +) { + val gestureState = flickState.gestureState + + if (gestureState is Dismissing) { + LaunchedEffect(Unit) { + delay(gestureState.animationDuration / 2) + onDismiss() + } + } +} + private fun launchShareSheet(scope: CoroutineScope, overlayHost: OverlayHost, state: State) = scope.launch { val result = diff --git a/gradle.properties b/gradle.properties index e8db3d087..b4bbbbbe6 100755 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,7 @@ org.gradle.parallel=true org.gradle.caching=true org.gradle.configureondemand=true -kotlin.incremental=false + org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 # Kapt controls diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d81dacf0..82e805482 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -125,6 +125,7 @@ androidx-compose-animation-graphics = { module = "androidx.compose.animation:ani androidx-compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose-compiler" } androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "compose" } androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "compose" } androidx-compose-uiTooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } androidx-compose-googleFonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "compose" } @@ -288,6 +289,7 @@ sqlite-xerial = { module = "org.xerial:sqlite-jdbc", version.ref = "xerial" } telephoto-zoomable = { module = "me.saket.telephoto:zoomable", version.ref = "telephoto" } telephoto-zoomableImage = { module = "me.saket.telephoto:zoomable-image", version.ref = "telephoto" } +telephoto-flick = { module = "me.saket.telephoto:flick", version.ref = "telephoto" } telephoto-zoomableImageCoil = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } tikxml-htmlEscape = { module = "com.tickaroo.tikxml:converter-htmlescape", version.ref = "tikxml" }