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

Docs - Shared element updates #1978

Merged
merged 6 commits into from
Mar 1, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 19 additions & 2 deletions docs/shared-elements-tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,15 @@ The call to `requireAnimatedScope` is accessing a `AnimatedVisibilityScope` that

=== "Android"
<div markdown>
<video style="float: left; margin-right: 0.8em;" width="400" controls="true" autoplay="true" loop="true" src="../videos/shared-elements-tutorial-step-4.mp4" ></video>
<video style="float: left; margin-right: 0.8em;" width="400" controls="true" autoplay="true" loop="true" src="../videos/shared-elements-tutorial-step-4-android.mp4" ></video>

With that we now have a shared element transition where the sender image transitions across the two screens!
</div>


=== "Desktop"
<div markdown>
<video style="float: left; margin-right: 0.8em;" width="400" controls="true" autoplay="true" loop="true" src="../videos/shared-elements-tutorial-step-4-desktop.mp4" ></video>

With that we now have a shared element transition where the sender image transitions across the two screens!
</div>
Expand Down Expand Up @@ -244,7 +252,16 @@ Text(
=== "Android"
<div markdown>

<video style="float: left; margin-right: 0.8em;" width="400" controls="true" autoplay="true" loop="true" src="../videos/shared-elements-tutorial-step-5.mp4" ></video>
<video style="float: left; margin-right: 0.8em;" width="400" controls="true" autoplay="true" loop="true" src="../videos/shared-elements-tutorial-step-5-android.mp4" ></video>

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`.

</div>

=== "Desktop"
<div markdown>

<video style="float: left; margin-right: 0.8em;" width="400" controls="true" autoplay="true" loop="true" src="../videos/shared-elements-tutorial-step-5-desktop.mp4" ></video>

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`.

Expand Down
4 changes: 4 additions & 0 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand All @@ -18,6 +18,7 @@ class MainActivity : AppCompatActivity() {
?.isAppearanceLightStatusBars = true

// TODO replace with your own impl if following the tutorial!
tutorialOnCreate()
introTutorialOnCreate()
// sharedElementsTutorialOnCreate()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DetailScreen.State> {
@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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Email>, 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<InboxScreen.State> {
@Composable
override fun present(): InboxScreen.State {
val emails by
produceState<List<Email>>(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)) },
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<InboxScreen, InboxScreen.State> { state, modifier -> Inbox(state, modifier) }
.addUi<DetailScreen, DetailScreen.State> { state, modifier -> EmailDetail(state, modifier) }
.build()
setContent {
MaterialTheme {
val backStack = rememberSaveableBackStack(InboxScreen)
val navigator = rememberCircuitNavigator(backStack)
CircuitCompositionLocals(circuit) {
SharedElementTransitionLayout {
NavigableCircuitContent(navigator = navigator, backStack = backStack)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading