Skip to content

Commit ee4f864

Browse files
authored
Merge pull request #1313 from square/ray/nav-polish
Completes our navigation story
2 parents f8c9e8c + 5c6a4fa commit ee4f864

File tree

9 files changed

+269
-12
lines changed

9 files changed

+269
-12
lines changed

samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,11 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime
1616
import com.squareup.workflow1.config.AndroidRuntimeConfigTools
1717
import com.squareup.workflow1.ui.Screen
1818
import com.squareup.workflow1.ui.WorkflowLayout
19+
import com.squareup.workflow1.ui.navigation.reportNavigation
1920
import com.squareup.workflow1.ui.renderWorkflowIn
20-
import com.squareup.workflow1.ui.unwrap
2121
import com.squareup.workflow1.ui.withRegistry
2222
import kotlinx.coroutines.flow.Flow
2323
import kotlinx.coroutines.flow.map
24-
import kotlinx.coroutines.flow.onEach
2524
import timber.log.Timber
2625

2726
private val viewRegistry = SampleContainers
@@ -53,8 +52,8 @@ class PoetryModel(savedState: SavedStateHandle) : ViewModel() {
5352
prop = 0 to 0 to Poem.allPoems,
5453
savedStateHandle = savedState,
5554
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
56-
).onEach {
57-
Timber.i("Navigated to %s", it.unwrap())
55+
).reportNavigation {
56+
Timber.i("Navigated to %s", it)
5857
}
5958
}
6059
}

samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,12 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime
1616
import com.squareup.workflow1.config.AndroidRuntimeConfigTools
1717
import com.squareup.workflow1.ui.Screen
1818
import com.squareup.workflow1.ui.WorkflowLayout
19+
import com.squareup.workflow1.ui.navigation.reportNavigation
1920
import com.squareup.workflow1.ui.renderWorkflowIn
20-
import com.squareup.workflow1.ui.unwrap
2121
import com.squareup.workflow1.ui.withRegistry
2222
import kotlinx.coroutines.Job
2323
import kotlinx.coroutines.flow.Flow
2424
import kotlinx.coroutines.flow.map
25-
import kotlinx.coroutines.flow.onEach
2625
import kotlinx.coroutines.launch
2726
import timber.log.Timber
2827

@@ -64,8 +63,8 @@ class RavenModel(savedState: SavedStateHandle) : ViewModel() {
6463
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
6564
) {
6665
running.complete()
67-
}.onEach {
68-
Timber.i("Navigated to %s", it.unwrap())
66+
}.reportNavigation {
67+
Timber.i("Navigated to %s", it)
6968
}
7069
}
7170

samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,12 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime
1414
import com.squareup.workflow1.config.AndroidRuntimeConfigTools
1515
import com.squareup.workflow1.ui.Screen
1616
import com.squareup.workflow1.ui.WorkflowLayout
17+
import com.squareup.workflow1.ui.navigation.reportNavigation
1718
import com.squareup.workflow1.ui.renderWorkflowIn
18-
import com.squareup.workflow1.ui.unwrap
1919
import com.squareup.workflow1.ui.withRegistry
2020
import kotlinx.coroutines.Job
2121
import kotlinx.coroutines.flow.Flow
2222
import kotlinx.coroutines.flow.map
23-
import kotlinx.coroutines.flow.onEach
2423
import kotlinx.coroutines.launch
2524
import timber.log.Timber
2625

@@ -63,8 +62,8 @@ class HelloBackButtonModel(savedState: SavedStateHandle) : ViewModel() {
6362
// This workflow handles the back button itself, so the activity can't.
6463
// Instead, the workflow emits an output to signal that it's time to shut things down.
6564
running.complete()
66-
}.onEach {
67-
Timber.i("Navigated to %s", it.unwrap())
65+
}.reportNavigation {
66+
Timber.i("Navigated to %s", it)
6867
}
6968
}
7069

workflow-ui/core-common/api/core-common.api

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ public abstract interface class com/squareup/workflow1/ui/Wrapper : com/squareup
154154
public abstract fun asSequence ()Lkotlin/sequences/Sequence;
155155
public abstract fun getCompatibilityKey ()Ljava/lang/String;
156156
public abstract fun getContent ()Ljava/lang/Object;
157+
public abstract fun getUnwrapped ()Ljava/lang/Object;
157158
public abstract fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Wrapper;
158159
}
159160

@@ -294,6 +295,18 @@ public final class com/squareup/workflow1/ui/navigation/FullScreenModal : com/sq
294295
public abstract interface class com/squareup/workflow1/ui/navigation/ModalOverlay : com/squareup/workflow1/ui/navigation/Overlay {
295296
}
296297

298+
public final class com/squareup/workflow1/ui/navigation/NavigationMonitor {
299+
public fun <init> ()V
300+
public fun <init> (ZLkotlin/jvm/functions/Function1;)V
301+
public synthetic fun <init> (ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
302+
public final fun update (Ljava/lang/Object;)V
303+
}
304+
305+
public final class com/squareup/workflow1/ui/navigation/NavigationMonitorKt {
306+
public static final fun reportNavigation (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow;
307+
public static synthetic fun reportNavigation$default (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow;
308+
}
309+
297310
public abstract interface class com/squareup/workflow1/ui/navigation/Overlay {
298311
}
299312

workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/Container.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ public interface Container<CategoryT, out C : CategoryT> : Composite<C> {
8787
public interface Wrapper<BaseT : Any, out C : BaseT> : Container<BaseT, C>, Compatible {
8888
public val content: C
8989

90+
override val unwrapped: Any get() = content
91+
9092
/**
9193
* Default implementation makes this [Wrapper] compatible with others of the same type,
9294
* and which wrap compatible [content].

workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BackStackScreen.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ public class BackStackScreen<out StackedT : Screen> internal constructor(
4848

4949
override val compatibilityKey: String = keyFor(this, name)
5050

51+
override val unwrapped: Any get() = top
52+
5153
override fun asSequence(): Sequence<StackedT> = frames.asSequence()
5254

5355
/**

workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BodyAndOverlaysScreen.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ public class BodyAndOverlaysScreen<B : Screen, O : Overlay>(
7979
) : Screen, Compatible, Composite<Any> {
8080
override val compatibilityKey: String = keyFor(this, name)
8181

82+
override val unwrapped: Any = overlays.lastOrNull() ?: body
83+
8284
override fun asSequence(): Sequence<Any> = sequenceOf(body) + overlays.asSequence()
8385

8486
public fun <S : Screen> mapBody(transform: (B) -> S): BodyAndOverlaysScreen<S, O> {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.squareup.workflow1.ui.navigation
2+
3+
import com.squareup.workflow1.ui.Compatible
4+
import com.squareup.workflow1.ui.unwrap
5+
import kotlinx.coroutines.flow.Flow
6+
import kotlinx.coroutines.flow.onEach
7+
8+
/**
9+
* Reports navigation across a series of calls to [update], probably made
10+
* for each rendering posted by
11+
* [renderWorkflowIn][com.squareup.workflow1.renderWorkflowIn].
12+
*
13+
* Takes advantage of [unwrap()] and [Compatible.keyFor] to provide navigation
14+
* logging by reporting the top (read: last-most, inner-most) sub-rendering,
15+
* which conventionally is the one that is visible and accessible to the user.
16+
*
17+
* Reports each time the [Compatible.keyFor] the top is unequal to the previous one,
18+
* which conventionally indicates that a new view object will replace the previous one.
19+
*/
20+
public class NavigationMonitor(
21+
skipFirstScreen: Boolean = false,
22+
private val onNavigate: (Any) -> Unit = { println(it) }
23+
) {
24+
@Volatile
25+
private var lastKey: String? = if (skipFirstScreen) null else ""
26+
27+
/**
28+
* Uses [unwrap] to find the topmost element of [rendering] and
29+
* reports it with [onNavigate] if [Compatible.keyFor] reveals that
30+
* it is of a different kind from the previous top.
31+
*/
32+
public fun update(rendering: Any) {
33+
val unwrapped = rendering.unwrap()
34+
35+
Compatible.keyFor(unwrapped).takeIf { it != lastKey }?.let { newKey ->
36+
if (lastKey != null) onNavigate(unwrapped)
37+
lastKey = newKey
38+
}
39+
}
40+
}
41+
42+
/**
43+
* Creates a [NavigationMonitor] and [updates it][NavigationMonitor.update]
44+
* with [each element collected][Flow.onEach] by the receiving [Flow].
45+
*/
46+
public fun <T : Any> Flow<T>.reportNavigation(
47+
skipFirstScreen: Boolean = false,
48+
onNavigate: (Any) -> Unit = { println(it) }
49+
): Flow<T> {
50+
val monitor = NavigationMonitor(skipFirstScreen, onNavigate)
51+
return onEach { monitor.update(it) }
52+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package com.squareup.workflow1.ui.navigation
2+
3+
import com.squareup.workflow1.ui.Compatible
4+
import com.squareup.workflow1.ui.Compatible.Companion.keyFor
5+
import com.squareup.workflow1.ui.Container
6+
import com.squareup.workflow1.ui.Screen
7+
import kotlin.test.Test
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertNotEquals
10+
import kotlin.test.assertNull
11+
import kotlin.test.assertSame
12+
13+
class NavigationMonitorTest {
14+
private data class NotScreen(
15+
val name: String,
16+
val baggage: String = ""
17+
) : Compatible {
18+
override val compatibilityKey: String = keyFor(this, name)
19+
}
20+
21+
private data class TestScreen(
22+
val name: String,
23+
val baggage: String = ""
24+
) : Screen, Compatible {
25+
override val compatibilityKey: String = keyFor(this, name)
26+
}
27+
28+
private data class TestContainer<T : Any>(
29+
val content: List<T>
30+
) : Container<Any, T> {
31+
override fun asSequence(): Sequence<T> = content.asSequence()
32+
33+
override fun <D : Any> map(transform: (T) -> D): Container<Any, D> = error("not relevant")
34+
}
35+
36+
private class TestOverlay<T : Screen>(
37+
override val content: T
38+
) : ScreenOverlay<T> {
39+
override fun <ContentU : Screen> map(transform: (T) -> ContentU) = error("not relevant")
40+
}
41+
42+
private var lastTop: Any? = null
43+
private var updates = 0
44+
45+
private fun onUpdate(top: Any) {
46+
lastTop = top
47+
updates++
48+
}
49+
50+
private val monitor = NavigationMonitor(onNavigate = ::onUpdate)
51+
52+
@Test
53+
fun `reports first by default`() {
54+
val screen = TestScreen("first")
55+
assertNull(lastTop)
56+
monitor.update(screen)
57+
assertSame(screen, lastTop)
58+
}
59+
60+
@Test
61+
fun `can skip first`() {
62+
val monitor = NavigationMonitor(skipFirstScreen = true, ::onUpdate)
63+
64+
assertNull(lastTop)
65+
monitor.update(TestScreen("first"))
66+
assertNull(lastTop)
67+
68+
monitor.update(TestScreen("second"))
69+
assertEquals(TestScreen("second"), lastTop)
70+
}
71+
72+
@Test
73+
fun `reports only on compatibility change`() {
74+
val type1Instance1 = TestScreen("first")
75+
assertEquals(0, updates)
76+
77+
monitor.update(type1Instance1)
78+
assertEquals(1, updates)
79+
80+
val type1Instance2 = type1Instance1.copy(baggage = "baggage")
81+
assertNotEquals(type1Instance1, type1Instance2)
82+
monitor.update(type1Instance2)
83+
assertEquals(1, updates)
84+
assertSame(type1Instance1, lastTop)
85+
86+
val type2 = TestScreen("second")
87+
monitor.update(type2)
88+
assertEquals(2, updates)
89+
assertSame(type2, lastTop)
90+
}
91+
92+
@Test
93+
fun `handles non-Screens`() {
94+
val first = NotScreen("first")
95+
96+
monitor.update(first)
97+
assertSame(first, lastTop)
98+
99+
monitor.update(first.copy(baggage = "fnord"))
100+
assertSame(first, lastTop)
101+
assertEquals(1, updates)
102+
103+
monitor.update(NotScreen("second", baggage = "fnord"))
104+
assertEquals(NotScreen("second", baggage = "fnord"), lastTop)
105+
assertEquals(2, updates)
106+
}
107+
108+
@Test
109+
fun unwraps() {
110+
monitor.update(container(TestScreen("0"), TestScreen("1"), TestScreen("2")))
111+
assertEquals(TestScreen("2"), lastTop)
112+
assertEquals(1, updates)
113+
114+
monitor.update(container(TestScreen("0"), TestScreen("1"), TestScreen("2")))
115+
assertEquals(TestScreen("2"), lastTop)
116+
assertEquals(1, updates)
117+
118+
monitor.update(container(TestScreen("0"), TestScreen("Hidden Update"), TestScreen("2")))
119+
assertEquals(TestScreen("2"), lastTop)
120+
assertEquals(1, updates)
121+
122+
monitor.update(container(TestScreen("0"), TestScreen("Hidden Update"), TestScreen("3")))
123+
assertEquals(TestScreen("3"), lastTop)
124+
assertEquals(2, updates)
125+
126+
monitor.update(container(TestScreen("3", "baggage")))
127+
assertEquals(TestScreen("3"), lastTop)
128+
assertEquals(2, updates)
129+
}
130+
131+
@Test
132+
fun `stock navigation types play nice`() {
133+
val body = TestScreen("Body")
134+
135+
monitor.update(bodyAndOverlays(body))
136+
assertSame(body, lastTop)
137+
138+
monitor.update(bodyAndOverlays(body.copy(baggage = "updated")))
139+
assertSame(body, lastTop)
140+
141+
val firstWindowBody = TestScreen("first window")
142+
monitor.update(bodyAndOverlays(body, TestOverlay(firstWindowBody)))
143+
assertSame(firstWindowBody, lastTop)
144+
145+
val wizardOne = TestScreen("wizard one")
146+
monitor.update(
147+
bodyAndOverlays(
148+
body,
149+
TestOverlay(firstWindowBody),
150+
TestOverlay(BackStackScreen(wizardOne))
151+
)
152+
)
153+
assertSame(wizardOne, lastTop)
154+
155+
monitor.update(
156+
bodyAndOverlays(
157+
body,
158+
TestOverlay(firstWindowBody),
159+
TestOverlay(BackStackScreen(wizardOne.copy(baggage = "updated")))
160+
)
161+
)
162+
assertSame(wizardOne, lastTop)
163+
164+
val wizardTwo = TestScreen("wizard two")
165+
monitor.update(
166+
bodyAndOverlays(
167+
body,
168+
TestOverlay(firstWindowBody),
169+
TestOverlay(
170+
BackStackScreen(
171+
wizardOne.copy(baggage = "updated"),
172+
wizardTwo
173+
)
174+
)
175+
)
176+
)
177+
assertSame(wizardTwo, lastTop)
178+
}
179+
180+
private fun <T : Any> container(vararg elements: T): TestContainer<T> =
181+
TestContainer(elements.toList())
182+
183+
private fun bodyAndOverlays(
184+
body: Screen,
185+
vararg overlays: Overlay
186+
): BodyAndOverlaysScreen<*, *> {
187+
return BodyAndOverlaysScreen(body, overlays.asList())
188+
}
189+
}

0 commit comments

Comments
 (0)