Skip to content

Commit

Permalink
Integrate image viewer in Star sample (#669)
Browse files Browse the repository at this point in the history
This adds a new image viewer in the STAR sample app. This gives us a
good testing ground for shared element transitions (#50) and also cleans
up a bit of the edge to edge behavior in the app.


https://github.com/slackhq/circuit/assets/1361086/5a343f95-395a-4c90-b4ec-de226f124387

Resolves #129
  • Loading branch information
ZacSweers authored Jun 24, 2023
1 parent c5c5b4b commit a450139
Show file tree
Hide file tree
Showing 34 changed files with 549 additions and 49 deletions.
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ okhttp = "5.0.0-alpha.11"
paparazzi = "1.3.0"
retrofit = "2.9.0"
robolectric = "4.10.3"
roborazzi = "1.2.0"
roborazzi = "1.4.0-alpha-2"
spotless = "6.18.0"
sqldelight = "2.0.0-rc01"
telephoto = "0.4.0"
turbine = "1.0.0"
versionsPlugin = "0.46.0"

Expand Down Expand Up @@ -195,6 +196,7 @@ retrofit-converters-scalars = { module = "com.squareup.retrofit2:converter-scala
robolectric = { module = "org.robolectric:robolectric", version.ref="robolectric" }

roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" }
roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" }
roborazzi-rules = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" }

rxjava = "io.reactivex.rxjava3:rxjava:3.1.6"
Expand All @@ -203,6 +205,8 @@ sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", ver
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions-jvm", version.ref = "sqldelight" }
sqldelight-primitiveAdapters = { module = "app.cash.sqldelight:primitive-adapters", version.ref = "sqldelight" }

telephoto-zoomableImageCoil = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }

testing-espresso-core = "androidx.test.espresso:espresso-core:3.5.1"
# Robolectric/Espresso ship with an old and totally borked version of hamcrest dependency, force a newer one
testing-hamcrest = "org.hamcrest:hamcrest:2.2"
Expand Down
5 changes: 4 additions & 1 deletion samples/star/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ dependencies {
implementation(libs.androidx.compose.accompanist.swiperefresh)
implementation(libs.androidx.compose.accompanist.systemUi)
implementation(libs.androidx.compose.ui.tooling)
implementation(libs.bundles.androidx.activity)
// Use a newer version for access to edgeToEdge APIs
implementation("androidx.activity:activity-ktx:1.8.0-alpha05")
implementation(libs.coil)
implementation(libs.coil.compose)
implementation(libs.eithernet)
Expand All @@ -97,6 +98,7 @@ dependencies {
implementation(libs.sqldelight.driver.android)
implementation(libs.sqldelight.coroutines)
implementation(libs.sqldelight.primitiveAdapters)
implementation(libs.telephoto.zoomableImageCoil)

testImplementation(libs.androidx.compose.ui.testing.junit)
testImplementation(libs.junit)
Expand All @@ -112,6 +114,7 @@ dependencies {
testImplementation(libs.androidx.compose.ui.testing.manifest)
testImplementation(libs.leakcanary.android.instrumentation)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.roborazzi.rules)
testImplementation(testFixtures(libs.eithernet))
testImplementation(projects.samples.star.coilRule)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.app.Activity
import android.net.Uri
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
Expand Down Expand Up @@ -47,6 +48,8 @@ class MainActivity @Inject constructor(private val circuitConfig: CircuitConfig)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

var backStack: ImmutableList<Screen> = persistentListOf(HomeScreen)
if (intent.data != null) {
val httpUrl = intent.data.toString().toHttpUrl()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,24 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.rememberVectorPainter

@Composable
fun BackPressNavIcon(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
iconButtonContent: @Composable () -> Unit = { ClosedIconImage() },
) {
val onBackPressedDispatcher =
LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
?: error("No local LocalOnBackPressedDispatcherOwner found.")
IconButton(modifier = modifier, onClick = onBackPressedDispatcher::onBackPressed) {
iconButtonContent()
val backPressOwner = LocalOnBackPressedDispatcherOwner.current
val finalOnClick = remember {
onClick
?: backPressOwner?.onBackPressedDispatcher?.let { dispatcher -> dispatcher::onBackPressed }
?: error("No local LocalOnBackPressedDispatcherOwner found.")
}
IconButton(modifier = modifier, onClick = finalOnClick) { iconButtonContent() }
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package com.slack.circuit.star.di
import com.slack.circuit.foundation.CircuitConfig
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuit.runtime.ui.Ui
import com.slack.circuit.star.imageviewer.ImageViewerAwareNavDecoration
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
Expand All @@ -26,6 +27,7 @@ interface CircuitModule {
return CircuitConfig.Builder()
.addPresenterFactories(presenterFactories)
.addUiFactories(uiFactories)
.setDefaultNavDecoration(ImageViewerAwareNavDecoration)
.build()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.star.home

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
Expand All @@ -18,7 +17,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import androidx.compose.ui.graphics.Color
import com.slack.circuit.codegen.annotations.CircuitInject
import com.slack.circuit.foundation.CircuitContent
import com.slack.circuit.foundation.NavEvent
Expand Down Expand Up @@ -69,13 +68,11 @@ fun HomePresenter(navigator: Navigator): HomeScreen.State {
@CircuitInject(screen = HomeScreen::class, scope = AppScope::class)
@Composable
fun HomeContent(state: HomeScreen.State, modifier: Modifier = Modifier) {
val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(MaterialTheme.colorScheme.background)
systemUiController.setNavigationBarColor(MaterialTheme.colorScheme.primaryContainer)

val eventSink = state.eventSink
Scaffold(
modifier = modifier.navigationBarsPadding().systemBarsPadding().fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
contentWindowInsets = WindowInsets(0, 0, 0, 0),
containerColor = Color.Transparent,
bottomBar = {
StarTheme(useDarkTheme = true) {
BottomNavigationBar(selectedIndex = state.selectedIndex) { index ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (C) 9690 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.star.imageviewer

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 com.slack.circuit.star.imageviewer.FlickToDismissState.FlickGestureState.Dismissed
import com.slack.circuit.star.imageviewer.FlickToDismissState.FlickGestureState.Dragging
import com.slack.circuit.star.imageviewer.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) }

@Suppress("MagicNumber")
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 ->
@Suppress("UnsafeCallOnNullableType")
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) {
@Suppress("UnsafeCallOnNullableType")
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
}
}
Loading

0 comments on commit a450139

Please sign in to comment.