Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP - Shared elements experimenting #1330

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ android.suppressUnsupportedCompileSdk=34
# NOTE releases must always be with the JB compiler for better native support
circuit.forceAndroidXComposeCompiler=false


circuit.config.enableSnapshots=true

# Versioning bits
GROUP=com.slack.circuit
POM_URL=https://github.com/slackhq/circuit/
Expand Down
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ atomicfu = "0.23.2"
benchmark = "1.2.3"
coil = "2.6.0"
coil3 = "3.0.0-alpha06"
compose-animation = "1.6.5"
compose-animation = "1.7.0-SNAPSHOT"
# Pre-release versions for testing Kotlin previews can be found here
# https://androidx.dev/storage/compose-compiler/repository
compose-compiler-version = "1.5.11"
Expand All @@ -20,7 +20,7 @@ compose-foundation = "1.6.5"
compose-material = "1.6.5"
compose-material3 = "1.2.1"
compose-runtime = "1.6.5"
compose-ui = "1.6.5"
compose-ui = "1.7.0-SNAPSHOT"
compose-jb = "1.6.1"
compose-jb-compiler = "1.5.10.1"
compose-jb-kotlinVersion = "1.9.23"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,27 @@ import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK
import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import com.slack.circuit.backstack.rememberSaveableBackStack
import com.slack.circuit.foundation.Circuit
import com.slack.circuit.foundation.CircuitCompositionLocals
import com.slack.circuit.foundation.NavigableCircuitContent
import com.slack.circuit.foundation.rememberCircuitNavigator
import com.slack.circuit.overlay.ContentWithOverlays
import com.slack.circuit.star.benchmark.ListBenchmarksScreen
import com.slack.circuit.star.di.ActivityKey
import com.slack.circuit.star.di.AppScope
import com.slack.circuit.star.home.HomeScreen
import com.slack.circuit.star.imageviewer.ImageViewerAwareNavDecoration
import com.slack.circuit.star.navigation.OpenUrlScreen
import com.slack.circuit.star.petdetail.PetDetailScreen
import com.slack.circuit.star.ui.SharedElementContentWithOverlays
import com.slack.circuit.star.ui.SharedElementNavDecoration
import com.slack.circuit.star.ui.StarTheme
import com.slack.circuitx.android.AndroidScreen
import com.slack.circuitx.android.IntentScreen
import com.slack.circuitx.android.rememberAndroidScreenAwareNavigator
import com.slack.circuitx.gesturenavigation.GestureNavigationDecoration
import com.squareup.anvil.annotations.ContributesMultibinding
import javax.inject.Inject
import kotlinx.collections.immutable.persistentListOf
Expand All @@ -41,6 +42,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
@ActivityKey(MainActivity::class)
class MainActivity @Inject constructor(private val circuit: Circuit) : AppCompatActivity() {

@OptIn(ExperimentalSharedTransitionApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Expand Down Expand Up @@ -70,16 +72,14 @@ class MainActivity @Inject constructor(private val circuit: Circuit) : AppCompat
Surface(color = MaterialTheme.colorScheme.background) {
val backStack = rememberSaveableBackStack(initialBackstack)
val circuitNavigator = rememberCircuitNavigator(backStack)
val navigator = rememberAndroidScreenAwareNavigator(circuitNavigator, this::goTo)
val navigator =
rememberAndroidScreenAwareNavigator(circuitNavigator, this@MainActivity::goTo)
CircuitCompositionLocals(circuit) {
ContentWithOverlays {
SharedElementContentWithOverlays {
NavigableCircuitContent(
navigator = navigator,
backStack = backStack,
decoration =
ImageViewerAwareNavDecoration(
GestureNavigationDecoration(onBackInvoked = navigator::pop)
),
decoration = SharedElementNavDecoration,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package com.slack.circuit.star.imageviewer

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
Expand All @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
Expand All @@ -21,15 +23,13 @@ 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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsControllerCompat
import coil.request.ImageRequest.Builder
import com.slack.circuit.backstack.NavDecoration
import com.slack.circuit.codegen.annotations.CircuitInject
import com.slack.circuit.foundation.NavigatorDefaults
import com.slack.circuit.foundation.RecordContentProvider
import com.slack.circuit.runtime.Navigator
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuit.star.common.BackPressNavIcon
Expand All @@ -38,12 +38,13 @@ import com.slack.circuit.star.imageviewer.FlickToDismissState.FlickGestureState.
import com.slack.circuit.star.imageviewer.ImageViewerScreen.Event.Close
import com.slack.circuit.star.imageviewer.ImageViewerScreen.Event.NoOp
import com.slack.circuit.star.imageviewer.ImageViewerScreen.State
import com.slack.circuit.star.ui.SharedElementTransitionScope
import com.slack.circuit.star.ui.StarTheme
import com.slack.circuit.star.ui.rememberSystemUiController
import com.slack.circuit.star.ui.sharedElementAnimatedContentScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.ImmutableList
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
Expand Down Expand Up @@ -73,9 +74,10 @@ constructor(
}
}

@OptIn(ExperimentalSharedTransitionApi::class)
@CircuitInject(ImageViewerScreen::class, AppScope::class)
@Composable
fun ImageViewer(state: State, modifier: Modifier = Modifier) {
fun ImageViewer(state: State, modifier: Modifier = Modifier) = SharedElementTransitionScope {
var showChrome by remember { mutableStateOf(true) }
val systemUiController = rememberSystemUiController()
systemUiController.isSystemBarsVisible = showChrome
Expand All @@ -95,7 +97,9 @@ fun ImageViewer(state: State, modifier: Modifier = Modifier) {
val backgroundAlpha: Float by
animateFloatAsState(targetValue = 1f, animationSpec = tween(), label = "backgroundAlpha")
Surface(
modifier.fillMaxSize().animateContentSize(),
modifier
.fillMaxSize()
.animateContentSize(),
color = Color.Black.copy(alpha = backgroundAlpha),
contentColor = Color.White,
) {
Expand All @@ -118,7 +122,17 @@ fun ImageViewer(state: State, modifier: Modifier = Modifier) {
.apply { state.placeholderKey?.let(::placeholderMemoryCacheKey) }
.build(),
contentDescription = "TODO",
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize()
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "animal-${state.id}"),
animatedVisibilityScope = sharedElementAnimatedContentScope(),
)
.sharedElement(
state = rememberSharedContentState(key = "animal-image-${state.id}"),
animatedVisibilityScope = sharedElementAnimatedContentScope(),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a particular reason why you are adding both sharedBounds and sharedElement on this item?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope! Was moving them around and alternating between the two.

)
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
,
state = imageState,
onClick = { showChrome = !showChrome },
)
Expand All @@ -136,27 +150,21 @@ fun ImageViewer(state: State, modifier: Modifier = Modifier) {
}
}

// TODO
// generalize this when there's a factory pattern for it in Circuit
// shared element transitions?
class ImageViewerAwareNavDecoration(
private val defaultNavDecoration: NavDecoration = NavigatorDefaults.DefaultDecoration
) : NavDecoration {
@Suppress("UnstableCollections")
@Composable
override fun <T> DecoratedContent(
args: ImmutableList<T>,
backStackDepth: Int,
modifier: Modifier,
content: @Composable (T) -> Unit,
) {
val firstArg = args.firstOrNull()
val decoration =
if (firstArg is RecordContentProvider<*> && firstArg.record.screen is ImageViewerScreen) {
NavigatorDefaults.EmptyDecoration
} else {
defaultNavDecoration
}
decoration.DecoratedContent(args, backStackDepth, modifier, content)
}
}
//// TODO
//// generalize this when there's a factory pattern for it in Circuit
//// shared element transitions?
// class ImageViewerAwareNavDecoration(
// private val defaultNavDecoration: NavDecoration = NavigatorDefaults.DefaultDecoration
// ) : NavDecoration {
// @Suppress("UnstableCollections")
// @Composable
// override fun <T> DecoratedContent(
// args: ImmutableList<T>,
// backStackDepth: Int,
// modifier: Modifier,
// content: @Composable (T) -> Unit,
// ) {
// remember { SharedElementNavDecoration(defaultNavDecoration) }
// .DecoratedContent(args, backStackDepth, modifier, content)
// }
// }
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.slack.circuit.star.petdetail

import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import coil3.SingletonImageLoader
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest.Builder
import com.slack.circuit.codegen.annotations.CircuitInject
import com.slack.circuit.runtime.internal.rememberStableCoroutineScope
import com.slack.circuit.star.di.AppScope
import com.slack.circuit.star.petdetail.PetPhotoCarouselScreen.State
import com.slack.circuit.star.petdetail.PetPhotoCarouselTestConstants.CAROUSEL_TAG
import com.slack.circuit.star.ui.HorizontalPagerIndicator
import com.slack.circuit.star.ui.SharedElementTransitionScope
import com.slack.circuit.star.ui.sharedElementAnimatedContentScope
import kotlinx.coroutines.launch

@OptIn(
ExperimentalSharedTransitionApi::class,
ExperimentalMaterial3WindowSizeClassApi::class,
ExperimentalFoundationApi::class,
ExperimentalAnimationApi::class,
)
@Composable
@CircuitInject(PetPhotoCarouselScreen::class, AppScope::class)
actual fun PetPhotoCarousel(state: State, modifier: Modifier) = SharedElementTransitionScope {
val (id, name, photoUrls, photoUrlMemoryCacheKey) = state
val context = LocalPlatformContext.current
// Prefetch images
LaunchedEffect(Unit) {
for (url in photoUrls) {
if (url.isBlank()) continue
val request = Builder(context).data(url).build()
SingletonImageLoader.get(context).enqueue(request)
}
}

val totalPhotos = photoUrls.size
val pagerState = rememberPagerState { totalPhotos }
val scope = rememberStableCoroutineScope()
val requester = remember { FocusRequester() }
@Suppress("MagicNumber")
val columnModifier =
when (calculateWindowSizeClass().widthSizeClass) {
WindowWidthSizeClass.Medium,
WindowWidthSizeClass.Expanded -> modifier.fillMaxWidth(0.5f)
else -> modifier.fillMaxSize()
}
val boundsTransform = { _: Rect, _: Rect -> tween<Rect>(1400) }
Column(
columnModifier
.testTag(CAROUSEL_TAG)
// Some images are different sizes. We probably want to constrain them to the same common
// size though
.animateContentSize()
.focusRequester(requester)
.focusable()
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyUp) return@onKeyEvent false
val index =
when (event.key) {
Key.DirectionRight -> {
pagerState.currentPage.inc().takeUnless { it >= totalPhotos } ?: -1
}
Key.DirectionLeft -> {
pagerState.currentPage.dec().takeUnless { it < 0 } ?: -1
}
else -> -1
}
if (index == -1) {
false
} else {
scope.launch { pagerState.animateScrollToPage(index) }
true
}
}
) {
PhotoPager(
pagerState = pagerState,
photoUrls = photoUrls,
name = name,
photoUrlMemoryCacheKey = photoUrlMemoryCacheKey,
modifier =
Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(key = "animal-${id}"),
animatedVisibilityScope = sharedElementAnimatedContentScope(),
boundsTransform = boundsTransform,
)
.sharedElement(
state = rememberSharedContentState(key = "animal-image-${id}"),
animatedVisibilityScope = sharedElementAnimatedContentScope(),
),
)

HorizontalPagerIndicator(
pagerState = pagerState,
pageCount = totalPhotos,
modifier = Modifier.align(Alignment.CenterHorizontally).padding(16.dp),
activeColor = MaterialTheme.colorScheme.onBackground,
)
}

// Focus the pager so we can cycle through it with arrow keys
LaunchedEffect(Unit) { requester.requestFocus() }
}
Loading
Loading