Skip to content

Commit 4f6678e

Browse files
authored
Merge pull request #307 from antonshilov/bottom-tab
Bottom tab navigation sample
2 parents e766b8d + d3d42fd commit 4f6678e

4 files changed

Lines changed: 200 additions & 75 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [#287](https://github.com/bumble-tech/appyx/pull/287)**Added**: `ImmutableList` has been added to avoid non-skippable compositions.
77
- [#289](https://github.com/bumble-tech/appyx/issues/289)**Added**: Introduced `interop-rx3` for RxJava 3 support. This has identical functionality to `interop-rx2`.
88
- [#298](https://github.com/bumble-tech/appyx/pulls/298)**Updated**: ChildView documentation. `TransitionDescriptor` generics has been renamed to `NavTarget` and `State`
9+
- [#307](https://github.com/bumble-tech/appyx/pull/307) - **Added**: `Spotlight.current()` method to observe currently active `NavTarget.
910

1011
---
1112

libraries/core/src/main/kotlin/com/bumble/appyx/navmodel/spotlight/SpotlightExt.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@ fun <T : Any> Spotlight<T>.hasPrevious() =
1111
fun <T : Any> Spotlight<T>.activeIndex() =
1212
elements.map { value -> value.currentIndex }
1313

14+
fun <T : Any> Spotlight<T>.current() =
15+
elements.map { value -> value.current?.key?.navTarget }
16+
1417
fun <T : Any> Spotlight<T>.elementsCount() =
1518
elements.value.size

samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/spotlight/SpotlightExampleNode.kt

Lines changed: 101 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,33 @@ package com.bumble.appyx.sandbox.client.spotlight
33
import android.os.Parcelable
44
import androidx.compose.foundation.layout.Arrangement
55
import androidx.compose.foundation.layout.Box
6-
import androidx.compose.foundation.layout.Column
76
import androidx.compose.foundation.layout.Row
87
import androidx.compose.foundation.layout.fillMaxSize
98
import androidx.compose.foundation.layout.fillMaxWidth
109
import androidx.compose.foundation.layout.padding
11-
import androidx.compose.material3.Button
10+
import androidx.compose.material.icons.Icons
11+
import androidx.compose.material.icons.filled.ArrowBack
12+
import androidx.compose.material.icons.filled.ArrowForward
13+
import androidx.compose.material.icons.filled.Favorite
14+
import androidx.compose.material.icons.outlined.FavoriteBorder
1215
import androidx.compose.material3.CircularProgressIndicator
16+
import androidx.compose.material3.ExperimentalMaterial3Api
17+
import androidx.compose.material3.FabPosition
18+
import androidx.compose.material3.FilledIconButton
19+
import androidx.compose.material3.Icon
20+
import androidx.compose.material3.IconButtonDefaults
21+
import androidx.compose.material3.NavigationBar
22+
import androidx.compose.material3.NavigationBarItem
23+
import androidx.compose.material3.Scaffold
1324
import androidx.compose.material3.Text
1425
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.State
1527
import androidx.compose.runtime.collectAsState
1628
import androidx.compose.runtime.getValue
1729
import androidx.compose.runtime.mutableStateOf
1830
import androidx.compose.ui.Alignment
1931
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.draw.shadow
2033
import androidx.compose.ui.unit.dp
2134
import androidx.lifecycle.coroutineScope
2235
import com.bumble.appyx.core.composable.Children
@@ -25,27 +38,27 @@ import com.bumble.appyx.core.node.Node
2538
import com.bumble.appyx.core.node.ParentNode
2639
import com.bumble.appyx.navmodel.spotlight.Spotlight
2740
import com.bumble.appyx.navmodel.spotlight.backpresshandler.GoToPrevious
41+
import com.bumble.appyx.navmodel.spotlight.current
2842
import com.bumble.appyx.navmodel.spotlight.elementsCount
2943
import com.bumble.appyx.navmodel.spotlight.hasNext
3044
import com.bumble.appyx.navmodel.spotlight.hasPrevious
31-
import com.bumble.appyx.navmodel.spotlight.operation.next
3245
import com.bumble.appyx.navmodel.spotlight.operation.activate
46+
import com.bumble.appyx.navmodel.spotlight.operation.next
3347
import com.bumble.appyx.navmodel.spotlight.operation.previous
3448
import com.bumble.appyx.navmodel.spotlight.operation.updateElements
35-
import com.bumble.appyx.navmodel.spotlight.transitionhandler.rememberSpotlightSlider
3649
import com.bumble.appyx.sandbox.client.child.ChildNode
37-
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.Item.C1
38-
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.Item.C2
39-
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.Item.C3
4050
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.NavTarget.Child1
4151
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.NavTarget.Child2
4252
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.NavTarget.Child3
43-
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.State.Loaded
44-
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.State.Loading
53+
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.ScreenState.Loaded
54+
import com.bumble.appyx.sandbox.client.spotlight.SpotlightExampleNode.ScreenState.Loading
4555
import kotlinx.coroutines.delay
4656
import kotlinx.coroutines.launch
4757
import kotlinx.parcelize.Parcelize
4858

59+
/**
60+
* Shows how to use spotlight to create a UI with BottomTabBar
61+
*/
4962
class SpotlightExampleNode(
5063
buildContext: BuildContext,
5164
private val spotlight: Spotlight<NavTarget> = Spotlight(
@@ -58,20 +71,20 @@ class SpotlightExampleNode(
5871
navModel = spotlight
5972
) {
6073

61-
private val screenState = mutableStateOf<State?>(null)
74+
private val screenState = mutableStateOf<ScreenState?>(null)
6275

63-
sealed class State {
64-
object Loading : State()
65-
object Loaded : State()
76+
sealed class ScreenState {
77+
object Loading : ScreenState()
78+
object Loaded : ScreenState()
6679
}
6780

6881
init {
6982
// simulate loading tabs
7083
if (spotlight.elementsCount() == 0) {
7184
screenState.value = Loading
7285
lifecycle.coroutineScope.launch {
73-
delay(2000)
74-
spotlight.updateElements(items = Item.getItemList())
86+
delay(1000)
87+
spotlight.updateElements(items = Tab.getTabList())
7588
screenState.value = Loaded
7689
}
7790
} else {
@@ -92,13 +105,13 @@ class SpotlightExampleNode(
92105
}
93106

94107
@Parcelize
95-
private enum class Item(val navTarget: NavTarget) : Parcelable {
108+
private enum class Tab(val navTarget: NavTarget) : Parcelable {
96109
C1(Child1),
97110
C2(Child2),
98111
C3(Child3);
99112

100113
companion object {
101-
fun getItemList() = values().map { it.navTarget }
114+
fun getTabList() = values().map { it.navTarget }
102115
}
103116
}
104117

@@ -126,82 +139,95 @@ class SpotlightExampleNode(
126139
}
127140
}
128141

142+
@OptIn(ExperimentalMaterial3Api::class)
129143
@Suppress("LongMethod")
130144
@Composable
131145
private fun LoadedState(modifier: Modifier = Modifier) {
132146
val hasPrevious = spotlight.hasPrevious().collectAsState(initial = false)
133147
val hasNext = spotlight.hasNext().collectAsState(initial = false)
134-
Column(
135-
verticalArrangement = Arrangement.spacedBy(24.dp),
136-
horizontalAlignment = Alignment.CenterHorizontally,
137-
modifier = modifier,
138-
) {
139-
Row(
140-
modifier = Modifier
141-
.fillMaxWidth()
142-
.padding(24.dp),
143-
verticalAlignment = Alignment.CenterVertically,
144-
horizontalArrangement = Arrangement.SpaceBetween
145-
) {
146-
TextButton(
147-
text = "Previous",
148-
enabled = hasPrevious.value
149-
) {
150-
spotlight.previous()
151-
}
152-
TextButton(
153-
text = "Next",
154-
enabled = hasNext.value
155-
) {
156-
spotlight.next()
157-
}
148+
val currentTab = spotlight.current().collectAsState(initial = null)
149+
Scaffold(
150+
modifier = modifier.fillMaxSize(),
151+
floatingActionButtonPosition = FabPosition.Center,
152+
floatingActionButton = { PageButtons(hasPrevious.value, hasNext.value) },
153+
bottomBar = {
154+
BottomTabs(currentTab)
158155
}
159-
Row(
160-
modifier = Modifier
161-
.fillMaxWidth()
162-
.padding(24.dp),
163-
verticalAlignment = Alignment.CenterVertically,
164-
horizontalArrangement = Arrangement.SpaceBetween
165-
) {
166-
TextButton(
167-
text = "C1",
168-
enabled = true
169-
) {
170-
spotlight.activate(C1)
171-
}
172-
TextButton(
173-
text = "C2",
174-
enabled = true
175-
) {
176-
spotlight.activate(C2)
177-
}
178-
TextButton(
179-
text = "C3",
180-
enabled = true
181-
) {
182-
spotlight.activate(C3)
183-
}
184-
}
185-
156+
) {
186157
Children(
187158
modifier = Modifier
188-
.padding(top = 12.dp, bottom = 12.dp)
189-
.fillMaxWidth(),
190-
transitionHandler = rememberSpotlightSlider(clipToBounds = true),
159+
.padding(it),
160+
transitionHandler = rememberSpotlightFaderThrough(),
191161
navModel = spotlight
192162
)
163+
}
164+
}
193165

166+
@Composable
167+
private fun BottomTabs(currentTab: State<NavTarget?>) {
168+
NavigationBar {
169+
Tab.values().forEach { tab ->
170+
val selected = currentTab.value == tab.navTarget
171+
NavigationBarItem(
172+
icon = {
173+
Icon(
174+
if (selected)
175+
Icons.Filled.Favorite
176+
else
177+
Icons.Outlined.FavoriteBorder,
178+
contentDescription = tab.toString()
179+
)
180+
},
181+
label = { Text(tab.toString()) },
182+
selected = selected,
183+
onClick = { spotlight.activate(tab) }
184+
)
185+
}
194186
}
195187
}
196188

197189
@Composable
198-
private fun TextButton(text: String, enabled: Boolean = true, onClick: () -> Unit) {
199-
Button(onClick = onClick, enabled = enabled, modifier = Modifier.padding(4.dp)) {
200-
Text(text = text)
190+
private fun PageButtons(
191+
hasPrevious: Boolean,
192+
hasNext: Boolean
193+
) {
194+
Row(
195+
modifier = Modifier
196+
.fillMaxWidth()
197+
.padding(24.dp),
198+
verticalAlignment = Alignment.CenterVertically,
199+
horizontalArrangement = Arrangement.SpaceBetween
200+
) {
201+
FilledIconButton(
202+
onClick = { spotlight.previous() },
203+
modifier = if (hasPrevious) Modifier.shadow(
204+
4.dp,
205+
IconButtonDefaults.filledShape
206+
) else Modifier,
207+
enabled = hasPrevious,
208+
) {
209+
Icon(
210+
Icons.Filled.ArrowBack,
211+
contentDescription = "Previous"
212+
)
213+
}
214+
FilledIconButton(
215+
onClick = { spotlight.next() },
216+
modifier = if (hasNext) Modifier.shadow(
217+
4.dp,
218+
IconButtonDefaults.filledShape
219+
) else Modifier,
220+
enabled = hasNext,
221+
) {
222+
Icon(
223+
Icons.Filled.ArrowForward,
224+
contentDescription = "Next"
225+
)
226+
}
201227
}
202228
}
203229

204-
private fun Spotlight<*>.activate(item: Item) {
230+
private fun Spotlight<*>.activate(item: Tab) {
205231
activate(item.ordinal)
206232
}
207233
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.bumble.appyx.sandbox.client.spotlight
2+
3+
import android.annotation.SuppressLint
4+
import androidx.compose.animation.core.FastOutSlowInEasing
5+
import androidx.compose.animation.core.Transition
6+
import androidx.compose.animation.core.animateFloat
7+
import androidx.compose.animation.core.tween
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.composed
12+
import androidx.compose.ui.draw.alpha
13+
import androidx.compose.ui.draw.scale
14+
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
15+
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
16+
import com.bumble.appyx.navmodel.spotlight.Spotlight
17+
18+
/**
19+
* Fade through transition from material design
20+
* [Specification](https://m2.material.io/design/motion/the-motion-system.html#fade-through)
21+
*/
22+
class SpotlightFaderThrough<T> : ModifierTransitionHandler<T, Spotlight.State>() {
23+
24+
@SuppressLint("ModifierFactoryExtensionFunction")
25+
override fun createModifier(
26+
modifier: Modifier,
27+
transition: Transition<Spotlight.State>,
28+
descriptor: TransitionDescriptor<T, Spotlight.State>
29+
): Modifier = modifier.composed {
30+
val alpha = transition.animateFloat(
31+
transitionSpec = {
32+
when (targetState) {
33+
Spotlight.State.ACTIVE -> tween(
34+
durationMillis = enterDuration,
35+
delayMillis = exitDuration,
36+
easing = FastOutSlowInEasing
37+
)
38+
39+
Spotlight.State.INACTIVE_BEFORE,
40+
Spotlight.State.INACTIVE_AFTER -> tween(
41+
durationMillis = exitDuration,
42+
easing = FastOutSlowInEasing
43+
)
44+
}
45+
46+
},
47+
targetValueByState = {
48+
when (it) {
49+
Spotlight.State.ACTIVE -> 1f
50+
else -> 0f
51+
}
52+
}, label = ""
53+
)
54+
val scale = transition.animateFloat(
55+
transitionSpec = {
56+
when (targetState) {
57+
Spotlight.State.ACTIVE -> tween(
58+
durationMillis = enterDuration,
59+
delayMillis = exitDuration,
60+
easing = FastOutSlowInEasing
61+
)
62+
63+
Spotlight.State.INACTIVE_BEFORE,
64+
Spotlight.State.INACTIVE_AFTER -> tween(
65+
durationMillis = exitDuration,
66+
easing = FastOutSlowInEasing
67+
)
68+
}
69+
70+
},
71+
targetValueByState = {
72+
when (it) {
73+
Spotlight.State.ACTIVE -> 1f
74+
else -> 0.92f
75+
}
76+
}, label = ""
77+
)
78+
79+
if (transition.targetState == Spotlight.State.ACTIVE) {
80+
scale(scale.value).alpha(alpha.value)
81+
} else {
82+
alpha(alpha.value)
83+
}
84+
85+
}
86+
87+
companion object {
88+
private const val enterDuration = 210
89+
private const val exitDuration = 90
90+
}
91+
}
92+
93+
@Composable
94+
fun <T> rememberSpotlightFaderThrough(): ModifierTransitionHandler<T, Spotlight.State> =
95+
remember { SpotlightFaderThrough() }

0 commit comments

Comments
 (0)