Skip to content

Commit

Permalink
Add AirshipEmbeddedViewGroup to support carousels and other custom la…
Browse files Browse the repository at this point in the history
…youts (#1561)
  • Loading branch information
jyaganeh authored Nov 1, 2024
1 parent ac6684b commit 7b1fe30
Show file tree
Hide file tree
Showing 18 changed files with 205 additions and 115 deletions.
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ buildscript {

// Android SDK Versions
minSdkVersion = 21
compileSdkVersion = 35
targetSdkVersion = 35
compileSdkVersion = 34
targetSdkVersion = 34

// Looking for dependency versions?
// See: ./gradle/libs.versions.toml
Expand Down
3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ google-truth = '1.1.3'
junit = '4.13.2'
mockito = '4.6.1'
mockito-kotlin = '4.0.0'
robolectric = '4.14-beta-1'
robolectric = '4.11.1'
turbine = '0.10.0'
mockk = '1.13.5'

Expand Down Expand Up @@ -144,6 +144,7 @@ androidx-fragment-testing = { module = "androidx.fragment:fragment-testing", ver
androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref="androidx-lifecycle" }
androidx-lifecycle-livedataktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref="androidx-lifecycle" }
androidx-lifecycle-runtimektx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref="androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref="androidx-lifecycle" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref="androidx-lifecycle" }
androidx-lifecycle-viewmodelktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref="androidx-lifecycle" }
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "androidx-navigation" }
Expand Down
3 changes: 0 additions & 3 deletions sample/src/main/res/values/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
<item name="colorOnSecondary">#000000</item>
<item name="messageCenterStyle">@style/AppTheme.MessageCenter</item>
<item name="android:statusBarColor" tools:ignore="NewApi">@color/airshipBlue</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:ignore="NewApi">true</item>
</style>

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



</resources>
4 changes: 1 addition & 3 deletions urbanairship-automation-compose/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ dependencies {
implementation(libs.compose.foundation)
implementation(libs.compose.runtime)
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.animation)

// Compose Preview Support
Expand All @@ -46,8 +45,7 @@ dependencies {
// AndroidX
implementation(libs.androidx.corektx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtimektx)
implementation(libs.androidx.lifecycle.viewmodelktx)
implementation(libs.androidx.lifecycle.runtime.compose)

// Instrumentation Tests
androidTestImplementation(platform(libs.compose.bom))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -163,7 +162,7 @@ private fun EmbeddedViewContent(
){ layout ->
EmbeddedViewWrapper(
embeddedId = state.embeddedId,
layout = layout,
embeddedLayout = layout,
embeddedSize = state.placementSize
?.toEmbeddedSize(parentWidthProvider, parentHeightProvider),
placeholder = placeholder,
Expand All @@ -174,64 +173,71 @@ private fun EmbeddedViewContent(
}
}

/** Shows the embedded view if content is available, otherwise shows the placeholder. */
/** Shows the [embeddedLayout] if not null, otherwise shows the [placeholder]. */
@Composable
private fun EmbeddedViewWrapper(
internal fun EmbeddedViewWrapper(
embeddedId: String,
layout: EmbeddedLayout?,
embeddedLayout: EmbeddedLayout?,
modifier: Modifier = Modifier,
embeddedSize: EmbeddedSize?,
placeholder: (@Composable () -> Unit)?
) {
if (layout != null) {
// Only nullable because we're safe-casting the placement type farther down.
// Placement should never be null here, in practice.
val (width, height) = embeddedSize ?: return

// Remember the view, updating it if the layout instance ID changes.
val view = remember(layout.viewInstanceId) {
mutableStateOf(
layout.makeView(width.fill, height.fill)?.apply {
layoutParams = LayoutParams(width.spec, height.spec).apply {
val layout = embeddedLayout ?: run {
// Show the placeholder if we have one
placeholder?.invoke()

// Bail out if we don't have a layout
return
}

// Only nullable because we're safe-casting the placement type farther down.
// Placement should never be null here, in practice.
val (width, height) = embeddedSize ?: run {
UALog.w("Embedded size is null for embedded ID \"$embeddedId\"!")
return
}

// Remember the view, updating it if the layout instance ID changes.
val view = remember(embeddedId, layout.viewInstanceId) {
embeddedLayout.makeView(width.fill, height.fill)!!.apply {
layoutParams = LayoutParams(width.spec, height.spec).apply {
gravity = Gravity.CENTER
}
}
}

AndroidView(
factory = { viewContext ->
FrameLayout(viewContext).apply {
layoutParams = LayoutParams(width.spec, height.spec)
}.also {
UALog.v { "Create embedded layout for id: \"$embeddedId\", instance: \"${embeddedLayout.viewInstanceId}\"" }
}
},
update = { frame ->
view.apply {
// Update the layout params to pass along size changes to the
// child embedded view.
updateLayoutParams {
LayoutParams(width.spec, height.spec).apply {
gravity = Gravity.CENTER
}
}
)
}

AndroidView(
factory = { viewContext ->
FrameLayout(viewContext).apply {
layoutParams = LayoutParams(width.spec, height.spec)
}.also {
UALog.v { "Create embedded layout for id: \"$embeddedId\"" }
// If the frame has children, remove them before adding the new view.
if (frame.childCount > 0) {
frame.removeAllViews()
}
},
update = { frame ->
view.value?.apply {
// Update the layout params to pass along size changes to the
// child embedded view.
updateLayoutParams {
LayoutParams(width.spec, height.spec).apply {
gravity = Gravity.CENTER
}
}
// If the frame is empty, add the view.
// The frame will be empty on the first update after the view is
// created, and when the frame is reset and then updated again.
if (frame.childCount == 0) {
frame.addView(this)
}
UALog.v { "Update embedded layout for id: \"$embeddedId\"" }
}
},
onReset = { frame ->
frame.removeAllViews()
UALog.v { "Reset embedded layout for id: \"$embeddedId\"" }
},
modifier = modifier
)
} else if (placeholder != null) {
placeholder()
}

frame.addView(this)

UALog.v { "Update embedded layout for id: \"$embeddedId\", instance: \"${embeddedLayout.viewInstanceId}\"}" }
}
},
onReset = { frame ->
frame.removeAllViews()
UALog.v { "Reset embedded layout for id: \"$embeddedId\", instance: \"${embeddedLayout.viewInstanceId}\"" }
},
modifier = modifier
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* Copyright Airship and Contributors */

package com.urbanairship.automation.compose

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.urbanairship.android.layout.EmbeddedDisplayRequest
import com.urbanairship.android.layout.ui.EmbeddedLayout
import com.urbanairship.embedded.AirshipEmbeddedInfo
import com.urbanairship.embedded.EmbeddedViewManager
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map

/**
* A container that allows all embedded content for the given [embeddedId]
* to be displayed using the provided [content] composable.
*
* @param embeddedId The embedded ID.
* @param modifier The modifier to be applied to the layout.
* @param comparator Optional [Comparator] used to sort available embedded view content.
* @param content The `Composable` that will display the list of embedded view content.
*/
@Composable
public fun AirshipEmbeddedViewGroup(
embeddedId: String,
modifier: Modifier = Modifier,
comparator: Comparator<AirshipEmbeddedInfo>? = null,
content: @Composable BoxScope.(embeddedViews: List<EmbeddedViewItem>) -> Unit
) {
val scope = rememberCoroutineScope()

val displayRequests = EmbeddedViewManager.displayRequests(embeddedId, comparator, scope)
.map { it.list }
.distinctUntilChanged()
.collectAsStateWithLifecycle(emptyList())

val items: State<List<EmbeddedViewItem>> = derivedStateOf(policy = structuralEqualityPolicy()) {
displayRequests.value.map { request -> EmbeddedViewItem(request = request) }
}

Box(modifier) {
content(items.value)
}
}

/**
* An embedded view item, containing the [AirshipEmbeddedInfo] and the content to display.
*/
@Immutable
public data class EmbeddedViewItem internal constructor(
private val request: EmbeddedDisplayRequest
) {
/** The [AirshipEmbeddedInfo] for this embedded content. */
public val info: AirshipEmbeddedInfo = AirshipEmbeddedInfo(
embeddedId = request.embeddedViewId,
instanceId = request.viewInstanceId,
priority = request.priority,
extras = request.extras,
)

/** The content to display for this embedded view item. */
@Composable
public fun content() {
val layout = EmbeddedLayout(
context = LocalContext.current,
embeddedViewId = request.embeddedViewId,
viewInstanceId = request.viewInstanceId,
args = request.displayArgsProvider.invoke(),
embeddedViewManager = EmbeddedViewManager
)

EmbeddedViewWrapper(
embeddedId = request.embeddedViewId,
embeddedLayout = layout,
embeddedSize = layout.getPlacement()?.size?.toEmbeddedSize(),
// Consumers provide their own placeholder, if desired.
placeholder = null,
modifier = Modifier.fillMaxWidth()
)
}
}

@Preview
@Composable
private fun AirshipEmbeddedViewPagerPreview() {
AirshipEmbeddedViewGroup(
embeddedId = "embeddedId",
modifier = Modifier.fillMaxSize()
) { views ->
views.first().content()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ public class AirshipEmbeddedViewState(

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

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

LaunchedEffect(key1 = embeddedId) {
LaunchedEffect(embeddedId, comparator) {
// Collect display requests and update the current layout state.
withContext(Dispatchers.Default) {
embeddedViewManager.displayRequests(embeddedId, comparator, scope)
.map { request ->
if (request == null) {
val next = request.next
if (next == null) {
// Nothing to display.
UALog.v { "No display request available for id: \"$embeddedId\"" }
null
} else {
// Inflate the embedded layout.
UALog.v { "Display request available for id: \"$embeddedId\"" }
val displayArgs = request.displayArgsProvider.invoke()
EmbeddedLayout(context, embeddedId, request.viewInstanceId, displayArgs, embeddedViewManager)
val displayArgs = next.displayArgsProvider.invoke()
EmbeddedLayout(context, embeddedId, next.viewInstanceId, displayArgs, embeddedViewManager)
}
}
.collect { state.currentLayout = it }
Expand All @@ -137,8 +136,8 @@ internal fun rememberAirshipEmbeddedViewState(
//

internal fun ConstrainedSize.toEmbeddedSize(
parentWidthProvider: (() -> Int)?,
parentHeightProvider: (() -> Int)?
parentWidthProvider: (() -> Int)? = null,
parentHeightProvider: (() -> Int)? = null
): EmbeddedSize =
EmbeddedSize(
width = width.toEmbeddedDimension(parentWidthProvider),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

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

0 comments on commit 7b1fe30

Please sign in to comment.