diff --git a/docs/shared-elements-tutorial.md b/docs/shared-elements-tutorial.md index 7cee85cd3..f52f1732f 100644 --- a/docs/shared-elements-tutorial.md +++ b/docs/shared-elements-tutorial.md @@ -176,7 +176,15 @@ The call to `requireAnimatedScope` is accessing a `AnimatedVisibilityScope` that === "Android"
- + + + With that we now have a shared element transition where the sender image transitions across the two screens! +
+ + +=== "Desktop" +
+ With that we now have a shared element transition where the sender image transitions across the two screens!
@@ -244,7 +252,16 @@ Text( === "Android"
- + + + After the `Modifier.sharedBounds()` is added to each of the three `Text` in the `EmailItem` composable and the `EmailDetailContent` composable you should now see the majority of the email tranistioning across the two `Screens`. + +
+ +=== "Desktop" +
+ + After the `Modifier.sharedBounds()` is added to each of the three `Text` in the `EmailItem` composable and the `EmailDetailContent` composable you should now see the majority of the email tranistioning across the two `Screens`. diff --git a/docs/tutorial.md b/docs/tutorial.md index 5e80762f9..296d14d3e 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -570,6 +570,10 @@ Naturally, navigation can't be just one way. The opposite of `Navigator.goTo()` On Android, `NavigableCircuitContent` automatically hooks into [BackHandler](https://developer.android.com/reference/kotlin/androidx/activity/compose/package-summary#BackHandler(kotlin.Boolean,kotlin.Function0)) to automatically pop on system back presses. On Desktop, it's recommended to wire the ESC key. +## Shared Elements + +You can continue this tutorial by seting up [shared element transitions](shared-elements-tutorial.md) between the Inbox and Detail screens. + ## Conclusion This is just a brief introduction to Circuit. For more information see various docs on the site, samples in the repo, the [API reference](api/0.x/index.html), and check out other Circuit tools like [circuit-retained](https://slackhq.github.io/circuit/presenter/#retention), [CircuitX](https://slackhq.github.io/circuit/circuitx/), [factory code gen](https://slackhq.github.io/circuit/code-gen/), [overlays](https://slackhq.github.io/circuit/overlays/), [navigation with results](https://slackhq.github.io/circuit/navigation/#results), [testing](https://slackhq.github.io/circuit/testing/), [multiplatform](https://slackhq.github.io/circuit/setup/#platform-support), and more. diff --git a/docs/videos/shared-elements-tutorial-step-4.mp4 b/docs/videos/shared-elements-tutorial-step-4-android.mp4 similarity index 100% rename from docs/videos/shared-elements-tutorial-step-4.mp4 rename to docs/videos/shared-elements-tutorial-step-4-android.mp4 diff --git a/docs/videos/shared-elements-tutorial-step-4-desktop.mp4 b/docs/videos/shared-elements-tutorial-step-4-desktop.mp4 new file mode 100644 index 000000000..02482634c Binary files /dev/null and b/docs/videos/shared-elements-tutorial-step-4-desktop.mp4 differ diff --git a/docs/videos/shared-elements-tutorial-step-5.mp4 b/docs/videos/shared-elements-tutorial-step-5-android.mp4 similarity index 100% rename from docs/videos/shared-elements-tutorial-step-5.mp4 rename to docs/videos/shared-elements-tutorial-step-5-android.mp4 diff --git a/docs/videos/shared-elements-tutorial-step-5-desktop.mp4 b/docs/videos/shared-elements-tutorial-step-5-desktop.mp4 new file mode 100644 index 000000000..604965b2c Binary files /dev/null and b/docs/videos/shared-elements-tutorial-step-5-desktop.mp4 differ diff --git a/mkdocs.yml b/mkdocs.yml index 19d92a6bd..4110d40c2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,7 +72,7 @@ nav: - 'Ui': ui.md - 'Overlays': overlays.md - 'Shared Elements': - 'Documentation': shared-elements.md + 'Usage': shared-elements.md 'Tutorial': shared-elements-tutorial.md - 'Testing': testing.md - 'Factories': factories.md diff --git a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/MainActivity.kt b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/MainActivity.kt index 663c865c0..035bf06c4 100644 --- a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/MainActivity.kt +++ b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/MainActivity.kt @@ -6,7 +6,7 @@ import android.os.Bundle import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat -import com.slack.circuit.tutorial.impl.tutorialOnCreate +import com.slack.circuit.tutorial.intro.introTutorialOnCreate class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -18,6 +18,7 @@ class MainActivity : AppCompatActivity() { ?.isAppearanceLightStatusBars = true // TODO replace with your own impl if following the tutorial! - tutorialOnCreate() + introTutorialOnCreate() + // sharedElementsTutorialOnCreate() } } diff --git a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/impl/DetailScreen.kt b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/intro/DetailScreen.kt similarity index 96% rename from samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/impl/DetailScreen.kt rename to samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/intro/DetailScreen.kt index 16432fb15..3db2bb893 100644 --- a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/impl/DetailScreen.kt +++ b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/intro/DetailScreen.kt @@ -1,6 +1,6 @@ // Copyright (C) 2024 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.tutorial.impl +package com.slack.circuit.tutorial.intro import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column @@ -25,8 +25,8 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.tutorial.common.Email -import com.slack.circuit.tutorial.common.EmailDetailContent import com.slack.circuit.tutorial.common.EmailRepository +import com.slack.circuit.tutorial.common.intro.EmailDetailContent import kotlinx.parcelize.Parcelize @Parcelize diff --git a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/impl/InboxScreen.kt b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/intro/InboxScreen.kt similarity index 96% rename from samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/impl/InboxScreen.kt rename to samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/intro/InboxScreen.kt index 1ea661cf7..4b23f8337 100644 --- a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/impl/InboxScreen.kt +++ b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/intro/InboxScreen.kt @@ -1,6 +1,6 @@ // Copyright (C) 2024 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.tutorial.impl +package com.slack.circuit.tutorial.intro import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -19,8 +19,8 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.tutorial.common.Email -import com.slack.circuit.tutorial.common.EmailItem import com.slack.circuit.tutorial.common.EmailRepository +import com.slack.circuit.tutorial.common.intro.EmailItem import kotlinx.parcelize.Parcelize @Parcelize diff --git a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/impl/MainActivityImpl.kt b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/intro/MainActivityImpl.kt similarity index 93% rename from samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/impl/MainActivityImpl.kt rename to samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/intro/MainActivityImpl.kt index 1cb46e2b8..6dc1862e8 100644 --- a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/impl/MainActivityImpl.kt +++ b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/intro/MainActivityImpl.kt @@ -1,6 +1,6 @@ // Copyright (C) 2024 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.tutorial.impl +package com.slack.circuit.tutorial.intro import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme @@ -12,7 +12,7 @@ import com.slack.circuit.foundation.rememberCircuitNavigator import com.slack.circuit.tutorial.MainActivity import com.slack.circuit.tutorial.common.EmailRepository -fun MainActivity.tutorialOnCreate() { +fun MainActivity.introTutorialOnCreate() { val emailRepository = EmailRepository() val circuit: Circuit = Circuit.Builder() diff --git a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/sharedelements/DetailScreen.kt b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/sharedelements/DetailScreen.kt new file mode 100644 index 000000000..31e3335a9 --- /dev/null +++ b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/sharedelements/DetailScreen.kt @@ -0,0 +1,90 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.tutorial.sharedelements + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.slack.circuit.runtime.CircuitContext +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.tutorial.common.Email +import com.slack.circuit.tutorial.common.EmailRepository +import com.slack.circuit.tutorial.common.sharedelements.EmailDetailContent +import kotlinx.parcelize.Parcelize + +@Parcelize +data class DetailScreen(val emailId: String) : Screen { + data class State(val email: Email, val eventSink: (Event) -> Unit) : CircuitUiState + + sealed interface Event : CircuitUiEvent { + data object BackClicked : Event + } +} + +class DetailPresenter( + private val screen: DetailScreen, + private val navigator: Navigator, + private val emailRepository: EmailRepository, +) : Presenter { + @Composable + override fun present(): DetailScreen.State { + val email = emailRepository.getEmail(screen.emailId) + return DetailScreen.State(email) { event -> + when (event) { + DetailScreen.Event.BackClicked -> navigator.pop() + } + } + } + + class Factory(private val emailRepository: EmailRepository) : Presenter.Factory { + override fun create( + screen: Screen, + navigator: Navigator, + context: CircuitContext, + ): Presenter<*>? { + return when (screen) { + is DetailScreen -> return DetailPresenter(screen, navigator, emailRepository) + else -> null + } + } + } +} + +@Composable +fun EmailDetail(state: DetailScreen.State, modifier: Modifier = Modifier) { + val subject by remember { derivedStateOf { state.email.subject } } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(subject) }, + navigationIcon = { + IconButton(onClick = { state.eventSink(DetailScreen.Event.BackClicked) }) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding), verticalArrangement = spacedBy(16.dp)) { + EmailDetailContent(state.email) + } + } +} diff --git a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/sharedelements/InboxScreen.kt b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/sharedelements/InboxScreen.kt new file mode 100644 index 000000000..7d3dcfda6 --- /dev/null +++ b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/sharedelements/InboxScreen.kt @@ -0,0 +1,76 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.tutorial.sharedelements + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import com.slack.circuit.runtime.CircuitContext +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.tutorial.common.Email +import com.slack.circuit.tutorial.common.EmailRepository +import com.slack.circuit.tutorial.common.sharedelements.EmailItem +import kotlinx.parcelize.Parcelize + +@Parcelize +data object InboxScreen : Screen { + data class State(val emails: List, val eventSink: (Event) -> Unit) : CircuitUiState + + sealed class Event : CircuitUiEvent { + data class EmailClicked(val emailId: String) : Event() + } +} + +class InboxPresenter( + private val navigator: Navigator, + private val emailRepository: EmailRepository, +) : Presenter { + @Composable + override fun present(): InboxScreen.State { + val emails by + produceState>(initialValue = emptyList()) { value = emailRepository.getEmails() } + return InboxScreen.State(emails) { event -> + when (event) { + is InboxScreen.Event.EmailClicked -> navigator.goTo(DetailScreen(event.emailId)) + } + } + } + + class Factory(private val emailRepository: EmailRepository) : Presenter.Factory { + override fun create( + screen: Screen, + navigator: Navigator, + context: CircuitContext, + ): Presenter<*>? { + return when (screen) { + InboxScreen -> return InboxPresenter(navigator, emailRepository) + else -> null + } + } + } +} + +@Composable +fun Inbox(state: InboxScreen.State, modifier: Modifier = Modifier) { + Scaffold(modifier = modifier, topBar = { TopAppBar(title = { Text("Inbox") }) }) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + items(state.emails) { email -> + EmailItem( + email = email, + onClick = { state.eventSink(InboxScreen.Event.EmailClicked(email.id)) }, + ) + } + } + } +} diff --git a/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/sharedelements/MainActivityImpl.kt b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/sharedelements/MainActivityImpl.kt new file mode 100644 index 000000000..2872098ab --- /dev/null +++ b/samples/tutorial/src/androidMain/kotlin/com/slack/circuit/tutorial/sharedelements/MainActivityImpl.kt @@ -0,0 +1,38 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.tutorial.sharedelements + +import androidx.activity.compose.setContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.material3.MaterialTheme +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.sharedelements.SharedElementTransitionLayout +import com.slack.circuit.tutorial.MainActivity +import com.slack.circuit.tutorial.common.EmailRepository + +@OptIn(ExperimentalSharedTransitionApi::class) +fun MainActivity.sharedElementsTutorialOnCreate() { + val emailRepository = EmailRepository() + val circuit: Circuit = + Circuit.Builder() + .addPresenterFactory(DetailPresenter.Factory(emailRepository)) + .addPresenterFactory(InboxPresenter.Factory(emailRepository)) + .addUi { state, modifier -> Inbox(state, modifier) } + .addUi { state, modifier -> EmailDetail(state, modifier) } + .build() + setContent { + MaterialTheme { + val backStack = rememberSaveableBackStack(InboxScreen) + val navigator = rememberCircuitNavigator(backStack) + CircuitCompositionLocals(circuit) { + SharedElementTransitionLayout { + NavigableCircuitContent(navigator = navigator, backStack = backStack) + } + } + } + } +} diff --git a/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/ui.kt b/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/intro/ui.kt similarity index 97% rename from samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/ui.kt rename to samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/intro/ui.kt index bfb124cdd..8dccd5d61 100644 --- a/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/ui.kt +++ b/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/intro/ui.kt @@ -1,6 +1,6 @@ // Copyright (C) 2024 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.tutorial.common +package com.slack.circuit.tutorial.common.intro import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.slack.circuit.tutorial.common.Email /** A simple email item to show in a list. */ @Composable diff --git a/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/sharedelements/EmailSharedTransitionKey.kt b/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/sharedelements/EmailSharedTransitionKey.kt new file mode 100644 index 000000000..3d4f8a8c6 --- /dev/null +++ b/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/sharedelements/EmailSharedTransitionKey.kt @@ -0,0 +1,14 @@ +// Copyright (C) 2025 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.tutorial.common.sharedelements + +import com.slack.circuit.sharedelements.SharedTransitionKey + +data class EmailSharedTransitionKey(val id: String, val type: ElementType) : SharedTransitionKey { + enum class ElementType { + SenderImage, + SenderName, + Subject, + Body, + } +} diff --git a/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/sharedelements/ui.kt b/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/sharedelements/ui.kt new file mode 100644 index 000000000..42f0748cf --- /dev/null +++ b/samples/tutorial/src/commonMain/kotlin/com/slack/circuit/tutorial/common/sharedelements/ui.kt @@ -0,0 +1,225 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.tutorial.common.sharedelements + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.slack.circuit.sharedelements.SharedElementTransitionScope +import com.slack.circuit.tutorial.common.Email + +/** A simple email item to show in a list. */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun EmailItem(email: Email, modifier: Modifier = Modifier, onClick: () -> Unit = {}) = + SharedElementTransitionScope { + Row( + modifier.clickable(onClick = onClick).padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Image( + Icons.Default.Person, + modifier = + Modifier.sharedElement( + state = + rememberSharedContentState( + EmailSharedTransitionKey( + id = email.id, + type = EmailSharedTransitionKey.ElementType.SenderImage, + ) + ), + animatedVisibilityScope = + requireAnimatedScope(SharedElementTransitionScope.AnimatedScope.Navigation), + ) + .size(40.dp) + .clip(CircleShape) + .background(Color.Magenta) + .padding(4.dp), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + Column { + Row { + Text( + text = email.sender, + modifier = + Modifier.sharedBounds( + sharedContentState = + rememberSharedContentState( + EmailSharedTransitionKey( + id = email.id, + type = EmailSharedTransitionKey.ElementType.SenderName, + ) + ), + animatedVisibilityScope = + requireAnimatedScope(SharedElementTransitionScope.AnimatedScope.Navigation), + ) + .weight(1f), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = email.timestamp, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(0.5f), + ) + } + + Text( + text = email.subject, + modifier = + Modifier.sharedBounds( + sharedContentState = + rememberSharedContentState( + EmailSharedTransitionKey( + id = email.id, + type = EmailSharedTransitionKey.ElementType.Subject, + ) + ), + animatedVisibilityScope = + requireAnimatedScope(SharedElementTransitionScope.AnimatedScope.Navigation), + ), + style = MaterialTheme.typography.labelLarge, + ) + Text( + text = email.body, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier.sharedBounds( + sharedContentState = + rememberSharedContentState( + EmailSharedTransitionKey( + id = email.id, + type = EmailSharedTransitionKey.ElementType.Body, + ) + ), + animatedVisibilityScope = + requireAnimatedScope(SharedElementTransitionScope.AnimatedScope.Navigation), + ) + .alpha(0.5f), + ) + } + } + } + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun EmailDetailContent(email: Email, modifier: Modifier = Modifier) = SharedElementTransitionScope { + Column(modifier.padding(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Image( + Icons.Default.Person, + modifier = + Modifier.sharedElement( + state = + rememberSharedContentState( + EmailSharedTransitionKey( + id = email.id, + type = EmailSharedTransitionKey.ElementType.SenderImage, + ) + ), + animatedVisibilityScope = + requireAnimatedScope(SharedElementTransitionScope.AnimatedScope.Navigation), + ) + .size(40.dp) + .clip(CircleShape) + .background(Color.Magenta) + .padding(4.dp), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = null, + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row { + Text( + text = email.sender, + modifier = + Modifier.sharedBounds( + sharedContentState = + rememberSharedContentState( + EmailSharedTransitionKey( + id = email.id, + type = EmailSharedTransitionKey.ElementType.SenderName, + ) + ), + animatedVisibilityScope = + requireAnimatedScope(SharedElementTransitionScope.AnimatedScope.Navigation), + ) + .weight(1f), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = email.timestamp, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alpha(0.5f), + ) + } + Text( + text = email.subject, + modifier = + Modifier.sharedBounds( + sharedContentState = + rememberSharedContentState( + EmailSharedTransitionKey( + id = email.id, + type = EmailSharedTransitionKey.ElementType.Subject, + ) + ), + animatedVisibilityScope = + requireAnimatedScope(SharedElementTransitionScope.AnimatedScope.Navigation), + ), + style = MaterialTheme.typography.labelMedium, + ) + Row { + Text("To: ", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + Text( + text = email.recipients.joinToString(","), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.alpha(0.5f), + ) + } + } + } + @Suppress("DEPRECATION") // Deprecated in Android but not yet available in CM + Divider(modifier = Modifier.padding(vertical = 16.dp)) + Text( + text = email.body, + Modifier.sharedBounds( + sharedContentState = + rememberSharedContentState( + EmailSharedTransitionKey( + id = email.id, + type = EmailSharedTransitionKey.ElementType.Body, + ) + ), + animatedVisibilityScope = + requireAnimatedScope(SharedElementTransitionScope.AnimatedScope.Navigation), + ), + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/impl/DetailScreen.kt b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/intro/DetailScreen.kt similarity index 96% rename from samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/impl/DetailScreen.kt rename to samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/intro/DetailScreen.kt index fadf7df61..aeb60c591 100644 --- a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/impl/DetailScreen.kt +++ b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/intro/DetailScreen.kt @@ -1,6 +1,6 @@ // Copyright (C) 2024 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.tutorial.impl +package com.slack.circuit.tutorial.intro import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column @@ -22,8 +22,8 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.tutorial.common.Email -import com.slack.circuit.tutorial.common.EmailDetailContent import com.slack.circuit.tutorial.common.EmailRepository +import com.slack.circuit.tutorial.common.intro.EmailDetailContent data class DetailScreen(val emailId: String) : Screen { data class State(val email: Email, val eventSink: (Event) -> Unit) : CircuitUiState diff --git a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/impl/InboxScreen.kt b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/intro/InboxScreen.kt similarity index 96% rename from samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/impl/InboxScreen.kt rename to samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/intro/InboxScreen.kt index e1b8a097c..51e7827c0 100644 --- a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/impl/InboxScreen.kt +++ b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/intro/InboxScreen.kt @@ -1,6 +1,6 @@ // Copyright (C) 2024 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.tutorial.impl +package com.slack.circuit.tutorial.intro import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -19,8 +19,8 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.tutorial.common.Email -import com.slack.circuit.tutorial.common.EmailItem import com.slack.circuit.tutorial.common.EmailRepository +import com.slack.circuit.tutorial.common.intro.EmailItem data object InboxScreen : Screen { data class State(val emails: List, val eventSink: (Event) -> Unit) : CircuitUiState diff --git a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/impl/main.kt b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/intro/main.kt similarity index 97% rename from samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/impl/main.kt rename to samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/intro/main.kt index d41a13b1b..66e3010dd 100644 --- a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/impl/main.kt +++ b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/intro/main.kt @@ -1,6 +1,6 @@ // Copyright (C) 2024 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.tutorial.impl +package com.slack.circuit.tutorial.intro import androidx.compose.material3.MaterialTheme import androidx.compose.ui.window.Window diff --git a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/main.kt b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/main.kt index 874847307..d0f504ef3 100644 --- a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/main.kt +++ b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/main.kt @@ -4,5 +4,6 @@ package com.slack.circuit.tutorial fun main() { // TODO replace with your own impl if following the tutorial! - com.slack.circuit.tutorial.impl.main() + com.slack.circuit.tutorial.intro.main() + // com.slack.circuit.tutorial.sharedelements.main() } diff --git a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/sharedelements/DetailScreen.kt b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/sharedelements/DetailScreen.kt new file mode 100644 index 000000000..ec9a21f15 --- /dev/null +++ b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/sharedelements/DetailScreen.kt @@ -0,0 +1,84 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.tutorial.sharedelements + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.slack.circuit.runtime.CircuitContext +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.tutorial.common.Email +import com.slack.circuit.tutorial.common.EmailRepository +import com.slack.circuit.tutorial.common.sharedelements.EmailDetailContent + +data class DetailScreen(val emailId: String) : Screen { + data class State(val email: Email, val eventSink: (Event) -> Unit) : CircuitUiState + + sealed interface Event : CircuitUiEvent { + data object BackClicked : Event + } +} + +class DetailPresenter( + private val screen: DetailScreen, + private val navigator: Navigator, + private val emailRepository: EmailRepository, +) : Presenter { + @Composable + override fun present(): DetailScreen.State { + val email = emailRepository.getEmail(screen.emailId) + return DetailScreen.State(email) { event -> + when (event) { + DetailScreen.Event.BackClicked -> navigator.pop() + } + } + } + + class Factory(private val emailRepository: EmailRepository) : Presenter.Factory { + override fun create( + screen: Screen, + navigator: Navigator, + context: CircuitContext, + ): Presenter<*>? { + return when (screen) { + is DetailScreen -> return DetailPresenter(screen, navigator, emailRepository) + else -> null + } + } + } +} + +@Composable +fun EmailDetail(state: DetailScreen.State, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(state.email.subject) }, + navigationIcon = { + IconButton(onClick = { state.eventSink(DetailScreen.Event.BackClicked) }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding), verticalArrangement = spacedBy(16.dp)) { + EmailDetailContent(state.email) + } + } +} diff --git a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/sharedelements/InboxScreen.kt b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/sharedelements/InboxScreen.kt new file mode 100644 index 000000000..e00f31c57 --- /dev/null +++ b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/sharedelements/InboxScreen.kt @@ -0,0 +1,74 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.tutorial.sharedelements + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import com.slack.circuit.runtime.CircuitContext +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.tutorial.common.Email +import com.slack.circuit.tutorial.common.EmailRepository +import com.slack.circuit.tutorial.common.sharedelements.EmailItem + +data object InboxScreen : Screen { + data class State(val emails: List, val eventSink: (Event) -> Unit) : CircuitUiState + + sealed class Event : CircuitUiEvent { + data class EmailClicked(val emailId: String) : Event() + } +} + +class InboxPresenter( + private val navigator: Navigator, + private val emailRepository: EmailRepository, +) : Presenter { + @Composable + override fun present(): InboxScreen.State { + val emails by + produceState>(initialValue = emptyList()) { value = emailRepository.getEmails() } + return InboxScreen.State(emails) { event -> + when (event) { + is InboxScreen.Event.EmailClicked -> navigator.goTo(DetailScreen(event.emailId)) + } + } + } + + class Factory(private val emailRepository: EmailRepository) : Presenter.Factory { + override fun create( + screen: Screen, + navigator: Navigator, + context: CircuitContext, + ): Presenter<*>? { + return when (screen) { + InboxScreen -> return InboxPresenter(navigator, emailRepository) + else -> null + } + } + } +} + +@Composable +fun Inbox(state: InboxScreen.State, modifier: Modifier = Modifier) { + Scaffold(modifier = modifier, topBar = { TopAppBar(title = { Text("Inbox") }) }) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + items(state.emails) { email -> + EmailItem( + email = email, + onClick = { state.eventSink(InboxScreen.Event.EmailClicked(email.id)) }, + ) + } + } + } +} diff --git a/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/sharedelements/main.kt b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/sharedelements/main.kt new file mode 100644 index 000000000..28c5ca067 --- /dev/null +++ b/samples/tutorial/src/jvmMain/kotlin/com/slack/circuit/tutorial/sharedelements/main.kt @@ -0,0 +1,40 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.tutorial.sharedelements + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +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.sharedelements.SharedElementTransitionLayout +import com.slack.circuit.tutorial.common.EmailRepository + +@OptIn(ExperimentalSharedTransitionApi::class) +fun main() { + val emailRepository = EmailRepository() + val circuit: Circuit = + Circuit.Builder() + .addPresenterFactory(DetailPresenter.Factory(emailRepository)) + .addPresenterFactory(InboxPresenter.Factory(emailRepository)) + .addUi { state, modifier -> Inbox(state, modifier) } + .addUi { state, modifier -> EmailDetail(state, modifier) } + .build() + application { + Window(title = "Tutorial", onCloseRequest = ::exitApplication) { + MaterialTheme { + val backStack = rememberSaveableBackStack(InboxScreen) + val navigator = rememberCircuitNavigator(backStack) { exitApplication() } + CircuitCompositionLocals(circuit) { + SharedElementTransitionLayout { + NavigableCircuitContent(navigator = navigator, backStack = backStack) + } + } + } + } + } +}