Skip to content

Commit 7b1fe30

Browse files
authored
Add AirshipEmbeddedViewGroup to support carousels and other custom layouts (#1561)
1 parent ac6684b commit 7b1fe30

File tree

18 files changed

+205
-115
lines changed

18 files changed

+205
-115
lines changed

build.gradle

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ buildscript {
88

99
// Android SDK Versions
1010
minSdkVersion = 21
11-
compileSdkVersion = 35
12-
targetSdkVersion = 35
11+
compileSdkVersion = 34
12+
targetSdkVersion = 34
1313

1414
// Looking for dependency versions?
1515
// See: ./gradle/libs.versions.toml

gradle/libs.versions.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ google-truth = '1.1.3'
8383
junit = '4.13.2'
8484
mockito = '4.6.1'
8585
mockito-kotlin = '4.0.0'
86-
robolectric = '4.14-beta-1'
86+
robolectric = '4.11.1'
8787
turbine = '0.10.0'
8888
mockk = '1.13.5'
8989

@@ -144,6 +144,7 @@ androidx-fragment-testing = { module = "androidx.fragment:fragment-testing", ver
144144
androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref="androidx-lifecycle" }
145145
androidx-lifecycle-livedataktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref="androidx-lifecycle" }
146146
androidx-lifecycle-runtimektx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref="androidx-lifecycle" }
147+
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref="androidx-lifecycle" }
147148
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref="androidx-lifecycle" }
148149
androidx-lifecycle-viewmodelktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref="androidx-lifecycle" }
149150
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "androidx-navigation" }

sample/src/main/res/values/styles.xml

-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
<item name="colorOnSecondary">#000000</item>
1515
<item name="messageCenterStyle">@style/AppTheme.MessageCenter</item>
1616
<item name="android:statusBarColor" tools:ignore="NewApi">@color/airshipBlue</item>
17-
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:ignore="NewApi">true</item>
1817
</style>
1918

2019
<style name="AppTheme.NoActionBar">
@@ -35,6 +34,4 @@
3534
<!-- Custom message date text style -->
3635
<style name="AppTheme.MessageCenter.DateTextAppearance" parent="TextAppearance.MaterialComponents.Body2" />
3736

38-
39-
4037
</resources>

urbanairship-automation-compose/build.gradle

+1-3
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ dependencies {
3636
implementation(libs.compose.foundation)
3737
implementation(libs.compose.runtime)
3838
implementation(libs.compose.ui)
39-
implementation(libs.compose.ui.graphics)
4039
implementation(libs.compose.animation)
4140

4241
// Compose Preview Support
@@ -46,8 +45,7 @@ dependencies {
4645
// AndroidX
4746
implementation(libs.androidx.corektx)
4847
implementation(libs.androidx.activity.compose)
49-
implementation(libs.androidx.lifecycle.runtimektx)
50-
implementation(libs.androidx.lifecycle.viewmodelktx)
48+
implementation(libs.androidx.lifecycle.runtime.compose)
5149

5250
// Instrumentation Tests
5351
androidTestImplementation(platform(libs.compose.bom))

urbanairship-automation-compose/src/main/java/com/urbanairship/automation/compose/AirshipEmbeddedView.kt

+57-51
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.Box
1717
import androidx.compose.foundation.layout.wrapContentSize
1818
import androidx.compose.foundation.text.BasicText
1919
import androidx.compose.runtime.Composable
20-
import androidx.compose.runtime.mutableStateOf
2120
import androidx.compose.runtime.remember
2221
import androidx.compose.ui.Alignment
2322
import androidx.compose.ui.Modifier
@@ -163,7 +162,7 @@ private fun EmbeddedViewContent(
163162
){ layout ->
164163
EmbeddedViewWrapper(
165164
embeddedId = state.embeddedId,
166-
layout = layout,
165+
embeddedLayout = layout,
167166
embeddedSize = state.placementSize
168167
?.toEmbeddedSize(parentWidthProvider, parentHeightProvider),
169168
placeholder = placeholder,
@@ -174,64 +173,71 @@ private fun EmbeddedViewContent(
174173
}
175174
}
176175

177-
/** Shows the embedded view if content is available, otherwise shows the placeholder. */
176+
/** Shows the [embeddedLayout] if not null, otherwise shows the [placeholder]. */
178177
@Composable
179-
private fun EmbeddedViewWrapper(
178+
internal fun EmbeddedViewWrapper(
180179
embeddedId: String,
181-
layout: EmbeddedLayout?,
180+
embeddedLayout: EmbeddedLayout?,
182181
modifier: Modifier = Modifier,
183182
embeddedSize: EmbeddedSize?,
184183
placeholder: (@Composable () -> Unit)?
185184
) {
186-
if (layout != null) {
187-
// Only nullable because we're safe-casting the placement type farther down.
188-
// Placement should never be null here, in practice.
189-
val (width, height) = embeddedSize ?: return
190-
191-
// Remember the view, updating it if the layout instance ID changes.
192-
val view = remember(layout.viewInstanceId) {
193-
mutableStateOf(
194-
layout.makeView(width.fill, height.fill)?.apply {
195-
layoutParams = LayoutParams(width.spec, height.spec).apply {
185+
val layout = embeddedLayout ?: run {
186+
// Show the placeholder if we have one
187+
placeholder?.invoke()
188+
189+
// Bail out if we don't have a layout
190+
return
191+
}
192+
193+
// Only nullable because we're safe-casting the placement type farther down.
194+
// Placement should never be null here, in practice.
195+
val (width, height) = embeddedSize ?: run {
196+
UALog.w("Embedded size is null for embedded ID \"$embeddedId\"!")
197+
return
198+
}
199+
200+
// Remember the view, updating it if the layout instance ID changes.
201+
val view = remember(embeddedId, layout.viewInstanceId) {
202+
embeddedLayout.makeView(width.fill, height.fill)!!.apply {
203+
layoutParams = LayoutParams(width.spec, height.spec).apply {
204+
gravity = Gravity.CENTER
205+
}
206+
}
207+
}
208+
209+
AndroidView(
210+
factory = { viewContext ->
211+
FrameLayout(viewContext).apply {
212+
layoutParams = LayoutParams(width.spec, height.spec)
213+
}.also {
214+
UALog.v { "Create embedded layout for id: \"$embeddedId\", instance: \"${embeddedLayout.viewInstanceId}\"" }
215+
}
216+
},
217+
update = { frame ->
218+
view.apply {
219+
// Update the layout params to pass along size changes to the
220+
// child embedded view.
221+
updateLayoutParams {
222+
LayoutParams(width.spec, height.spec).apply {
196223
gravity = Gravity.CENTER
197224
}
198225
}
199-
)
200-
}
201226

202-
AndroidView(
203-
factory = { viewContext ->
204-
FrameLayout(viewContext).apply {
205-
layoutParams = LayoutParams(width.spec, height.spec)
206-
}.also {
207-
UALog.v { "Create embedded layout for id: \"$embeddedId\"" }
227+
// If the frame has children, remove them before adding the new view.
228+
if (frame.childCount > 0) {
229+
frame.removeAllViews()
208230
}
209-
},
210-
update = { frame ->
211-
view.value?.apply {
212-
// Update the layout params to pass along size changes to the
213-
// child embedded view.
214-
updateLayoutParams {
215-
LayoutParams(width.spec, height.spec).apply {
216-
gravity = Gravity.CENTER
217-
}
218-
}
219-
// If the frame is empty, add the view.
220-
// The frame will be empty on the first update after the view is
221-
// created, and when the frame is reset and then updated again.
222-
if (frame.childCount == 0) {
223-
frame.addView(this)
224-
}
225-
UALog.v { "Update embedded layout for id: \"$embeddedId\"" }
226-
}
227-
},
228-
onReset = { frame ->
229-
frame.removeAllViews()
230-
UALog.v { "Reset embedded layout for id: \"$embeddedId\"" }
231-
},
232-
modifier = modifier
233-
)
234-
} else if (placeholder != null) {
235-
placeholder()
236-
}
231+
232+
frame.addView(this)
233+
234+
UALog.v { "Update embedded layout for id: \"$embeddedId\", instance: \"${embeddedLayout.viewInstanceId}\"}" }
235+
}
236+
},
237+
onReset = { frame ->
238+
frame.removeAllViews()
239+
UALog.v { "Reset embedded layout for id: \"$embeddedId\", instance: \"${embeddedLayout.viewInstanceId}\"" }
240+
},
241+
modifier = modifier
242+
)
237243
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/* Copyright Airship and Contributors */
2+
3+
package com.urbanairship.automation.compose
4+
5+
import androidx.compose.foundation.layout.Box
6+
import androidx.compose.foundation.layout.BoxScope
7+
import androidx.compose.foundation.layout.fillMaxSize
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.runtime.Composable
10+
import androidx.compose.runtime.Immutable
11+
import androidx.compose.runtime.State
12+
import androidx.compose.runtime.collectAsState
13+
import androidx.compose.runtime.derivedStateOf
14+
import androidx.compose.runtime.rememberCoroutineScope
15+
import androidx.compose.runtime.structuralEqualityPolicy
16+
import androidx.compose.ui.Modifier
17+
import androidx.compose.ui.platform.LocalContext
18+
import androidx.compose.ui.tooling.preview.Preview
19+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
20+
import com.urbanairship.android.layout.EmbeddedDisplayRequest
21+
import com.urbanairship.android.layout.ui.EmbeddedLayout
22+
import com.urbanairship.embedded.AirshipEmbeddedInfo
23+
import com.urbanairship.embedded.EmbeddedViewManager
24+
import kotlinx.coroutines.flow.distinctUntilChanged
25+
import kotlinx.coroutines.flow.map
26+
27+
/**
28+
* A container that allows all embedded content for the given [embeddedId]
29+
* to be displayed using the provided [content] composable.
30+
*
31+
* @param embeddedId The embedded ID.
32+
* @param modifier The modifier to be applied to the layout.
33+
* @param comparator Optional [Comparator] used to sort available embedded view content.
34+
* @param content The `Composable` that will display the list of embedded view content.
35+
*/
36+
@Composable
37+
public fun AirshipEmbeddedViewGroup(
38+
embeddedId: String,
39+
modifier: Modifier = Modifier,
40+
comparator: Comparator<AirshipEmbeddedInfo>? = null,
41+
content: @Composable BoxScope.(embeddedViews: List<EmbeddedViewItem>) -> Unit
42+
) {
43+
val scope = rememberCoroutineScope()
44+
45+
val displayRequests = EmbeddedViewManager.displayRequests(embeddedId, comparator, scope)
46+
.map { it.list }
47+
.distinctUntilChanged()
48+
.collectAsStateWithLifecycle(emptyList())
49+
50+
val items: State<List<EmbeddedViewItem>> = derivedStateOf(policy = structuralEqualityPolicy()) {
51+
displayRequests.value.map { request -> EmbeddedViewItem(request = request) }
52+
}
53+
54+
Box(modifier) {
55+
content(items.value)
56+
}
57+
}
58+
59+
/**
60+
* An embedded view item, containing the [AirshipEmbeddedInfo] and the content to display.
61+
*/
62+
@Immutable
63+
public data class EmbeddedViewItem internal constructor(
64+
private val request: EmbeddedDisplayRequest
65+
) {
66+
/** The [AirshipEmbeddedInfo] for this embedded content. */
67+
public val info: AirshipEmbeddedInfo = AirshipEmbeddedInfo(
68+
embeddedId = request.embeddedViewId,
69+
instanceId = request.viewInstanceId,
70+
priority = request.priority,
71+
extras = request.extras,
72+
)
73+
74+
/** The content to display for this embedded view item. */
75+
@Composable
76+
public fun content() {
77+
val layout = EmbeddedLayout(
78+
context = LocalContext.current,
79+
embeddedViewId = request.embeddedViewId,
80+
viewInstanceId = request.viewInstanceId,
81+
args = request.displayArgsProvider.invoke(),
82+
embeddedViewManager = EmbeddedViewManager
83+
)
84+
85+
EmbeddedViewWrapper(
86+
embeddedId = request.embeddedViewId,
87+
embeddedLayout = layout,
88+
embeddedSize = layout.getPlacement()?.size?.toEmbeddedSize(),
89+
// Consumers provide their own placeholder, if desired.
90+
placeholder = null,
91+
modifier = Modifier.fillMaxWidth()
92+
)
93+
}
94+
}
95+
96+
@Preview
97+
@Composable
98+
private fun AirshipEmbeddedViewPagerPreview() {
99+
AirshipEmbeddedViewGroup(
100+
embeddedId = "embeddedId",
101+
modifier = Modifier.fillMaxSize()
102+
) { views ->
103+
views.first().content()
104+
}
105+
}

urbanairship-automation-compose/src/main/java/com/urbanairship/automation/compose/AirshipEmbeddedViewState.kt

+8-9
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,7 @@ public class AirshipEmbeddedViewState(
6868

6969
/** Dismiss all pending embedded content for the current embedded view ID. */
7070
public suspend fun dismissAll(): Unit = coroutineScope {
71-
currentLayout?.let { layout ->
72-
EmbeddedViewManager.dismissAll(layout.embeddedViewId)
73-
}
71+
EmbeddedViewManager.dismissAll(embeddedId)
7472
}
7573

7674
internal val placementSize: ConstrainedSize? by derivedStateOf {
@@ -109,20 +107,21 @@ internal fun rememberAirshipEmbeddedViewState(
109107
val state = remember { AirshipEmbeddedViewState(embeddedId) }
110108
val scope = rememberCoroutineScope()
111109

112-
LaunchedEffect(key1 = embeddedId) {
110+
LaunchedEffect(embeddedId, comparator) {
113111
// Collect display requests and update the current layout state.
114112
withContext(Dispatchers.Default) {
115113
embeddedViewManager.displayRequests(embeddedId, comparator, scope)
116114
.map { request ->
117-
if (request == null) {
115+
val next = request.next
116+
if (next == null) {
118117
// Nothing to display.
119118
UALog.v { "No display request available for id: \"$embeddedId\"" }
120119
null
121120
} else {
122121
// Inflate the embedded layout.
123122
UALog.v { "Display request available for id: \"$embeddedId\"" }
124-
val displayArgs = request.displayArgsProvider.invoke()
125-
EmbeddedLayout(context, embeddedId, request.viewInstanceId, displayArgs, embeddedViewManager)
123+
val displayArgs = next.displayArgsProvider.invoke()
124+
EmbeddedLayout(context, embeddedId, next.viewInstanceId, displayArgs, embeddedViewManager)
126125
}
127126
}
128127
.collect { state.currentLayout = it }
@@ -137,8 +136,8 @@ internal fun rememberAirshipEmbeddedViewState(
137136
//
138137

139138
internal fun ConstrainedSize.toEmbeddedSize(
140-
parentWidthProvider: (() -> Int)?,
141-
parentHeightProvider: (() -> Int)?
139+
parentWidthProvider: (() -> Int)? = null,
140+
parentHeightProvider: (() -> Int)? = null
142141
): EmbeddedSize =
143142
EmbeddedSize(
144143
width = width.toEmbeddedDimension(parentWidthProvider),

urbanairship-automation/src/main/java/com/urbanairship/embedded/AirshipEmbeddedView.kt

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
2828
import kotlinx.coroutines.Job
2929
import kotlinx.coroutines.SupervisorJob
3030
import kotlinx.coroutines.cancelChildren
31+
import kotlinx.coroutines.flow.map
3132
import kotlinx.coroutines.launch
3233

3334
/**
@@ -219,6 +220,7 @@ public class AirshipEmbeddedView private constructor(
219220
displayRequestsJob = viewScope.launch {
220221
try {
221222
manager.displayRequests(embeddedViewId = id, comparator = comparator, scope = viewScope)
223+
.map { it.next }
222224
.collect(::onUpdate)
223225
} catch (e: CancellationException) {
224226
UALog.v { "Stopped collecting display requests for $logTag" }

0 commit comments

Comments
 (0)