diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d199e47d1..ceb15fa575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Fixed: - Fix a layout bug where children of fixed-with `Row` containers were assigned the wrong width. - Fix inconsistencies between iOS and Android for `Column` and `Row` layouts. +Breaking: +- Replace `CodeListener` with `RedwoodView.Root`. This puts the loading/error/ready state with the UI that displays that state. + ## [0.15.0] - 2024-09-30 [0.15.0]: https://github.com/cashapp/redwood/releases/tag/0.15.0 diff --git a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt index 546af5a625..256a1e5eb4 100644 --- a/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt +++ b/redwood-compose/src/commonMain/kotlin/app/cash/redwood/compose/RedwoodComposition.kt @@ -65,7 +65,7 @@ public fun RedwoodComposition( widgetSystem: WidgetSystem, onEndChanges: () -> Unit = {}, ): RedwoodComposition { - view.reset() + view.root.children.remove(0, view.root.children.widgets.size) val saveableStateRegistry = view.savedStateRegistry?.let { viewRegistry -> val state = viewRegistry.consumeRestoredState() @@ -95,7 +95,7 @@ public fun RedwoodComposition( return RedwoodComposition( scope, - view.children, + view.root.children, view.onBackPressedDispatcher, saveableStateRegistry, view.uiConfiguration, diff --git a/redwood-treehouse-host-composeui/api/android/redwood-treehouse-host-composeui.api b/redwood-treehouse-host-composeui/api/android/redwood-treehouse-host-composeui.api index 0953244033..54705d514f 100644 --- a/redwood-treehouse-host-composeui/api/android/redwood-treehouse-host-composeui.api +++ b/redwood-treehouse-host-composeui/api/android/redwood-treehouse-host-composeui.api @@ -1,4 +1,18 @@ +public class app/cash/redwood/treehouse/composeui/ComposeUiRoot : app/cash/redwood/widget/RedwoodView$Root { + public static final field $stable I + public fun ()V + public fun Render (Landroidx/compose/runtime/Composer;I)V + public fun contentState (IZLjava/lang/Throwable;)V + public synthetic fun getChildren ()Lapp/cash/redwood/widget/Widget$Children; + public fun getChildren ()Lapp/cash/redwood/widget/compose/ComposeWidgetChildren; + public fun getModifier ()Lapp/cash/redwood/Modifier; + public synthetic fun getValue ()Ljava/lang/Object; + public fun getValue ()Lkotlin/jvm/functions/Function2; + public fun restart (Lkotlin/jvm/functions/Function0;)V + public fun setModifier (Lapp/cash/redwood/Modifier;)V +} + public final class app/cash/redwood/treehouse/composeui/TreehouseContentKt { - public static final fun TreehouseContent (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView$WidgetSystem;Lapp/cash/redwood/treehouse/TreehouseContentSource;Landroidx/compose/ui/Modifier;Lapp/cash/redwood/treehouse/CodeListener;Landroidx/compose/runtime/Composer;II)V + public static final fun TreehouseContent (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView$WidgetSystem;Lapp/cash/redwood/treehouse/TreehouseContentSource;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } diff --git a/redwood-treehouse-host-composeui/api/jvm/redwood-treehouse-host-composeui.api b/redwood-treehouse-host-composeui/api/jvm/redwood-treehouse-host-composeui.api index 0953244033..54705d514f 100644 --- a/redwood-treehouse-host-composeui/api/jvm/redwood-treehouse-host-composeui.api +++ b/redwood-treehouse-host-composeui/api/jvm/redwood-treehouse-host-composeui.api @@ -1,4 +1,18 @@ +public class app/cash/redwood/treehouse/composeui/ComposeUiRoot : app/cash/redwood/widget/RedwoodView$Root { + public static final field $stable I + public fun ()V + public fun Render (Landroidx/compose/runtime/Composer;I)V + public fun contentState (IZLjava/lang/Throwable;)V + public synthetic fun getChildren ()Lapp/cash/redwood/widget/Widget$Children; + public fun getChildren ()Lapp/cash/redwood/widget/compose/ComposeWidgetChildren; + public fun getModifier ()Lapp/cash/redwood/Modifier; + public synthetic fun getValue ()Ljava/lang/Object; + public fun getValue ()Lkotlin/jvm/functions/Function2; + public fun restart (Lkotlin/jvm/functions/Function0;)V + public fun setModifier (Lapp/cash/redwood/Modifier;)V +} + public final class app/cash/redwood/treehouse/composeui/TreehouseContentKt { - public static final fun TreehouseContent (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView$WidgetSystem;Lapp/cash/redwood/treehouse/TreehouseContentSource;Landroidx/compose/ui/Modifier;Lapp/cash/redwood/treehouse/CodeListener;Landroidx/compose/runtime/Composer;II)V + public static final fun TreehouseContent (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView$WidgetSystem;Lapp/cash/redwood/treehouse/TreehouseContentSource;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } diff --git a/redwood-treehouse-host-composeui/api/redwood-treehouse-host-composeui.klib.api b/redwood-treehouse-host-composeui/api/redwood-treehouse-host-composeui.klib.api index 5180321d51..879b63bc97 100644 --- a/redwood-treehouse-host-composeui/api/redwood-treehouse-host-composeui.klib.api +++ b/redwood-treehouse-host-composeui/api/redwood-treehouse-host-composeui.klib.api @@ -6,4 +6,24 @@ // - Show declarations: true // Library unique name: -final fun <#A: app.cash.redwood.treehouse/AppService> app.cash.redwood.treehouse.composeui/TreehouseContent(app.cash.redwood.treehouse/TreehouseApp<#A>, app.cash.redwood.treehouse/TreehouseView.WidgetSystem>, app.cash.redwood.treehouse/TreehouseContentSource<#A>, androidx.compose.ui/Modifier?, app.cash.redwood.treehouse/CodeListener?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // app.cash.redwood.treehouse.composeui/TreehouseContent|TreehouseContent(app.cash.redwood.treehouse.TreehouseApp<0:0>;app.cash.redwood.treehouse.TreehouseView.WidgetSystem>;app.cash.redwood.treehouse.TreehouseContentSource<0:0>;androidx.compose.ui.Modifier?;app.cash.redwood.treehouse.CodeListener?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] +open class app.cash.redwood.treehouse.composeui/ComposeUiRoot : app.cash.redwood.widget/RedwoodView.Root> { // app.cash.redwood.treehouse.composeui/ComposeUiRoot|null[0] + constructor () // app.cash.redwood.treehouse.composeui/ComposeUiRoot.|(){}[0] + + open val children // app.cash.redwood.treehouse.composeui/ComposeUiRoot.children|{}children[0] + open fun (): app.cash.redwood.widget.compose/ComposeWidgetChildren // app.cash.redwood.treehouse.composeui/ComposeUiRoot.children.|(){}[0] + open val value // app.cash.redwood.treehouse.composeui/ComposeUiRoot.value|{}value[0] + open fun (): kotlin/Function2 // app.cash.redwood.treehouse.composeui/ComposeUiRoot.value.|(){}[0] + + open var modifier // app.cash.redwood.treehouse.composeui/ComposeUiRoot.modifier|{}modifier[0] + open fun (): app.cash.redwood/Modifier // app.cash.redwood.treehouse.composeui/ComposeUiRoot.modifier.|(){}[0] + open fun (app.cash.redwood/Modifier) // app.cash.redwood.treehouse.composeui/ComposeUiRoot.modifier.|(app.cash.redwood.Modifier){}[0] + + open fun Render(androidx.compose.runtime/Composer?, kotlin/Int) // app.cash.redwood.treehouse.composeui/ComposeUiRoot.Render|Render(androidx.compose.runtime.Composer?;kotlin.Int){}[0] + open fun contentState(kotlin/Int, kotlin/Boolean, kotlin/Throwable?) // app.cash.redwood.treehouse.composeui/ComposeUiRoot.contentState|contentState(kotlin.Int;kotlin.Boolean;kotlin.Throwable?){}[0] + open fun restart(kotlin/Function0?) // app.cash.redwood.treehouse.composeui/ComposeUiRoot.restart|restart(kotlin.Function0?){}[0] +} + +final val app.cash.redwood.treehouse.composeui/app_cash_redwood_treehouse_composeui_ComposeUiRoot$stableprop // app.cash.redwood.treehouse.composeui/app_cash_redwood_treehouse_composeui_ComposeUiRoot$stableprop|#static{}app_cash_redwood_treehouse_composeui_ComposeUiRoot$stableprop[0] + +final fun <#A: app.cash.redwood.treehouse/AppService> app.cash.redwood.treehouse.composeui/TreehouseContent(app.cash.redwood.treehouse/TreehouseApp<#A>, app.cash.redwood.treehouse/TreehouseView.WidgetSystem>, app.cash.redwood.treehouse/TreehouseContentSource<#A>, androidx.compose.ui/Modifier?, kotlin/Function1>>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // app.cash.redwood.treehouse.composeui/TreehouseContent|TreehouseContent(app.cash.redwood.treehouse.TreehouseApp<0:0>;app.cash.redwood.treehouse.TreehouseView.WidgetSystem>;app.cash.redwood.treehouse.TreehouseContentSource<0:0>;androidx.compose.ui.Modifier?;kotlin.Function1>>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] +final fun app.cash.redwood.treehouse.composeui/app_cash_redwood_treehouse_composeui_ComposeUiRoot$stableprop_getter(): kotlin/Int // app.cash.redwood.treehouse.composeui/app_cash_redwood_treehouse_composeui_ComposeUiRoot$stableprop_getter|app_cash_redwood_treehouse_composeui_ComposeUiRoot$stableprop_getter(){}[0] diff --git a/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/ComposeUiRoot.kt b/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/ComposeUiRoot.kt new file mode 100644 index 0000000000..d20aa1f2bd --- /dev/null +++ b/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/ComposeUiRoot.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse.composeui + +import androidx.compose.runtime.Composable +import app.cash.redwood.Modifier +import app.cash.redwood.widget.RedwoodView +import app.cash.redwood.widget.compose.ComposeWidgetChildren + +/** + * A default base implementation of [RedwoodView.Root]. + * + * This composition contributes nothing to the view hierarchy. It delegates directly to its child + * views. + */ +public open class ComposeUiRoot : RedwoodView.Root<@Composable () -> Unit> { + override val children: ComposeWidgetChildren = ComposeWidgetChildren() + + override var modifier: Modifier = Modifier + + override fun contentState( + loadCount: Int, + attached: Boolean, + uncaughtException: Throwable?, + ) { + } + + override fun restart(restart: (() -> Unit)?) { + } + + override val value: @Composable () -> Unit = { + Render() + } + + @Composable + public open fun Render() { + children.Render() + } +} diff --git a/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt b/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt index 1a3cde5464..cff17f4909 100644 --- a/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt +++ b/redwood-treehouse-host-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged @@ -31,7 +32,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import app.cash.redwood.composeui.safeAreaInsets import app.cash.redwood.treehouse.AppService -import app.cash.redwood.treehouse.CodeListener import app.cash.redwood.treehouse.StateSnapshot import app.cash.redwood.treehouse.TreehouseApp import app.cash.redwood.treehouse.TreehouseContentSource @@ -45,8 +45,9 @@ import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.Size import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.ui.dp as redwoodDp +import app.cash.redwood.widget.RedwoodView import app.cash.redwood.widget.SavedStateRegistry -import app.cash.redwood.widget.compose.ComposeWidgetChildren +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @Composable @@ -55,9 +56,10 @@ public fun TreehouseContent( widgetSystem: WidgetSystem<@Composable () -> Unit>, contentSource: TreehouseContentSource, modifier: Modifier = Modifier, - codeListener: CodeListener = remember { CodeListener() }, + root: ((CoroutineScope) -> RedwoodView.Root<@Composable () -> Unit>) = { _ -> ComposeUiRoot() }, ) { val onBackPressedDispatcher = platformOnBackPressedDispatcher() + val scope = rememberCoroutineScope() var viewportSize: Size? by remember { mutableStateOf(null) } val density = LocalDensity.current @@ -71,10 +73,9 @@ public fun TreehouseContent( LayoutDirection.Rtl -> RedwoodLayoutDirection.Rtl }, ) - val treehouseView = remember(widgetSystem) { object : TreehouseView<@Composable () -> Unit> { - override val children = ComposeWidgetChildren() + override val root: RedwoodView.Root<@Composable () -> Unit> = root(scope) override val onBackPressedDispatcher = onBackPressedDispatcher override val uiConfiguration = MutableStateFlow(uiConfiguration) @@ -86,14 +87,13 @@ public fun TreehouseContent( override var readyForContentChangeListener: ReadyForContentChangeListener<@Composable () -> Unit>? = null override var saveCallback: TreehouseView.SaveCallback? = null override val stateSnapshotId = StateSnapshot.Id(null) - override fun reset() = children.remove(0, children.widgets.size) } } LaunchedEffect(treehouseView, uiConfiguration) { treehouseView.uiConfiguration.value = uiConfiguration } - DisposableEffect(treehouseView, contentSource, codeListener) { - val closeable = contentSource.bindWhenReady(treehouseView, treehouseApp, codeListener) + DisposableEffect(treehouseView, contentSource) { + val closeable = contentSource.bindWhenReady(treehouseView, treehouseApp) onDispose { closeable.close() } @@ -106,7 +106,7 @@ public fun TreehouseContent( } }, ) { - treehouseView.children.Render() + treehouseView.root.value() } } diff --git a/redwood-treehouse-host/api/android/redwood-treehouse-host.api b/redwood-treehouse-host/api/android/redwood-treehouse-host.api index 5c0b484c50..4baae7fb67 100644 --- a/redwood-treehouse-host/api/android/redwood-treehouse-host.api +++ b/redwood-treehouse-host/api/android/redwood-treehouse-host.api @@ -3,13 +3,6 @@ public final class app/cash/redwood/treehouse/ChangeListRenderer { public final fun render-rqC_l18 (Lapp/cash/redwood/treehouse/TreehouseView;Ljava/util/List;)V } -public class app/cash/redwood/treehouse/CodeListener { - public fun ()V - public fun onCodeDetached (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView;Ljava/lang/Throwable;)V - public fun onCodeLoaded (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView;Z)V - public fun onInitialCodeLoading (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView;)V -} - public abstract interface class app/cash/redwood/treehouse/Content { public abstract fun awaitContent (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun awaitContent$default (Lapp/cash/redwood/treehouse/Content;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; @@ -20,8 +13,7 @@ public abstract interface class app/cash/redwood/treehouse/Content { public final class app/cash/redwood/treehouse/ContentBindingKt { public static final fun bindWhenReady (Lapp/cash/redwood/treehouse/Content;Lapp/cash/redwood/treehouse/TreehouseView;)Ljava/io/Closeable; - public static final fun bindWhenReady (Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/TreehouseView;Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/CodeListener;)Ljava/io/Closeable; - public static synthetic fun bindWhenReady$default (Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/TreehouseView;Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/CodeListener;ILjava/lang/Object;)Ljava/io/Closeable; + public static final fun bindWhenReady (Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/TreehouseView;Lapp/cash/redwood/treehouse/TreehouseApp;)Ljava/io/Closeable; } public class app/cash/redwood/treehouse/EventListener { @@ -82,8 +74,7 @@ public abstract interface class app/cash/redwood/treehouse/StateStore { public abstract class app/cash/redwood/treehouse/TreehouseApp : java/lang/AutoCloseable { public fun ()V public abstract fun close ()V - public abstract fun createContent (Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/CodeListener;)Lapp/cash/redwood/treehouse/Content; - public static synthetic fun createContent$default (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/CodeListener;ILjava/lang/Object;)Lapp/cash/redwood/treehouse/Content; + public abstract fun createContent (Lapp/cash/redwood/treehouse/TreehouseContentSource;)Lapp/cash/redwood/treehouse/Content; public abstract fun getDispatchers ()Lapp/cash/redwood/treehouse/TreehouseDispatchers; public abstract fun getName ()Ljava/lang/String; public abstract fun getZipline ()Lkotlinx/coroutines/flow/StateFlow; @@ -126,8 +117,8 @@ public abstract interface class app/cash/redwood/treehouse/TreehouseDispatchers } public final class app/cash/redwood/treehouse/TreehouseLayout : app/cash/redwood/widget/RedwoodLayout, app/cash/redwood/treehouse/TreehouseView { - public fun (Landroid/content/Context;Lapp/cash/redwood/treehouse/TreehouseView$WidgetSystem;Landroidx/activity/OnBackPressedDispatcher;)V - public synthetic fun generateDefaultLayoutParams ()Landroid/view/ViewGroup$LayoutParams; + public fun (Landroid/content/Context;Lapp/cash/redwood/treehouse/TreehouseView$WidgetSystem;Landroidx/activity/OnBackPressedDispatcher;Lapp/cash/redwood/widget/RedwoodView$Root;)V + public synthetic fun (Landroid/content/Context;Lapp/cash/redwood/treehouse/TreehouseView$WidgetSystem;Landroidx/activity/OnBackPressedDispatcher;Lapp/cash/redwood/widget/RedwoodView$Root;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getReadyForContent ()Z public fun getReadyForContentChangeListener ()Lapp/cash/redwood/treehouse/TreehouseView$ReadyForContentChangeListener; public fun getSaveCallback ()Lapp/cash/redwood/treehouse/TreehouseView$SaveCallback; diff --git a/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api b/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api index 2c2e003e3e..f25b952acd 100644 --- a/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api +++ b/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api @@ -3,13 +3,6 @@ public final class app/cash/redwood/treehouse/ChangeListRenderer { public final fun render-rqC_l18 (Lapp/cash/redwood/treehouse/TreehouseView;Ljava/util/List;)V } -public class app/cash/redwood/treehouse/CodeListener { - public fun ()V - public fun onCodeDetached (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView;Ljava/lang/Throwable;)V - public fun onCodeLoaded (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView;Z)V - public fun onInitialCodeLoading (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseView;)V -} - public abstract interface class app/cash/redwood/treehouse/Content { public abstract fun awaitContent (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun awaitContent$default (Lapp/cash/redwood/treehouse/Content;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; @@ -20,8 +13,7 @@ public abstract interface class app/cash/redwood/treehouse/Content { public final class app/cash/redwood/treehouse/ContentBindingKt { public static final fun bindWhenReady (Lapp/cash/redwood/treehouse/Content;Lapp/cash/redwood/treehouse/TreehouseView;)Ljava/io/Closeable; - public static final fun bindWhenReady (Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/TreehouseView;Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/CodeListener;)Ljava/io/Closeable; - public static synthetic fun bindWhenReady$default (Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/TreehouseView;Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/CodeListener;ILjava/lang/Object;)Ljava/io/Closeable; + public static final fun bindWhenReady (Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/TreehouseView;Lapp/cash/redwood/treehouse/TreehouseApp;)Ljava/io/Closeable; } public class app/cash/redwood/treehouse/EventListener { @@ -82,8 +74,7 @@ public abstract interface class app/cash/redwood/treehouse/StateStore { public abstract class app/cash/redwood/treehouse/TreehouseApp : java/lang/AutoCloseable { public fun ()V public abstract fun close ()V - public abstract fun createContent (Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/CodeListener;)Lapp/cash/redwood/treehouse/Content; - public static synthetic fun createContent$default (Lapp/cash/redwood/treehouse/TreehouseApp;Lapp/cash/redwood/treehouse/TreehouseContentSource;Lapp/cash/redwood/treehouse/CodeListener;ILjava/lang/Object;)Lapp/cash/redwood/treehouse/Content; + public abstract fun createContent (Lapp/cash/redwood/treehouse/TreehouseContentSource;)Lapp/cash/redwood/treehouse/Content; public abstract fun getDispatchers ()Lapp/cash/redwood/treehouse/TreehouseDispatchers; public abstract fun getName ()Ljava/lang/String; public abstract fun getZipline ()Lkotlinx/coroutines/flow/StateFlow; diff --git a/redwood-treehouse-host/api/redwood-treehouse-host.klib.api b/redwood-treehouse-host/api/redwood-treehouse-host.klib.api index 555255cf7d..b75bb9232b 100644 --- a/redwood-treehouse-host/api/redwood-treehouse-host.klib.api +++ b/redwood-treehouse-host/api/redwood-treehouse-host.klib.api @@ -72,7 +72,7 @@ abstract class <#A: app.cash.redwood.treehouse/AppService> app.cash.redwood.tree abstract fun (): kotlinx.coroutines.flow/StateFlow // app.cash.redwood.treehouse/TreehouseApp.zipline.|(){}[0] abstract fun close() // app.cash.redwood.treehouse/TreehouseApp.close|close(){}[0] - abstract fun createContent(app.cash.redwood.treehouse/TreehouseContentSource<#A>, app.cash.redwood.treehouse/CodeListener = ...): app.cash.redwood.treehouse/Content // app.cash.redwood.treehouse/TreehouseApp.createContent|createContent(app.cash.redwood.treehouse.TreehouseContentSource<1:0>;app.cash.redwood.treehouse.CodeListener){}[0] + abstract fun createContent(app.cash.redwood.treehouse/TreehouseContentSource<#A>): app.cash.redwood.treehouse/Content // app.cash.redwood.treehouse/TreehouseApp.createContent|createContent(app.cash.redwood.treehouse.TreehouseContentSource<1:0>){}[0] abstract fun restart() // app.cash.redwood.treehouse/TreehouseApp.restart|restart(){}[0] abstract fun start() // app.cash.redwood.treehouse/TreehouseApp.start|start(){}[0] abstract fun stop() // app.cash.redwood.treehouse/TreehouseApp.stop|stop(){}[0] @@ -115,6 +115,7 @@ final class app.cash.redwood.treehouse/MemoryStateStore : app.cash.redwood.treeh final class app.cash.redwood.treehouse/TreehouseUIView : app.cash.redwood.treehouse/TreehouseView, app.cash.redwood.widget/RedwoodUIView { // app.cash.redwood.treehouse/TreehouseUIView|null[0] constructor (app.cash.redwood.treehouse/TreehouseView.WidgetSystem) // app.cash.redwood.treehouse/TreehouseUIView.|(app.cash.redwood.treehouse.TreehouseView.WidgetSystem){}[0] + constructor (app.cash.redwood.treehouse/TreehouseView.WidgetSystem, app.cash.redwood.widget/UIViewRoot) // app.cash.redwood.treehouse/TreehouseUIView.|(app.cash.redwood.treehouse.TreehouseView.WidgetSystem;app.cash.redwood.widget.UIViewRoot){}[0] final val readyForContent // app.cash.redwood.treehouse/TreehouseUIView.readyForContent|{}readyForContent[0] final fun (): kotlin/Boolean // app.cash.redwood.treehouse/TreehouseUIView.readyForContent.|(){}[0] @@ -130,14 +131,8 @@ final class app.cash.redwood.treehouse/TreehouseUIView : app.cash.redwood.treeho final var stateSnapshotId // app.cash.redwood.treehouse/TreehouseUIView.stateSnapshotId|{}stateSnapshotId[0] final fun (): app.cash.redwood.treehouse/StateSnapshot.Id // app.cash.redwood.treehouse/TreehouseUIView.stateSnapshotId.|(){}[0] final fun (app.cash.redwood.treehouse/StateSnapshot.Id) // app.cash.redwood.treehouse/TreehouseUIView.stateSnapshotId.|(app.cash.redwood.treehouse.StateSnapshot.Id){}[0] -} - -open class app.cash.redwood.treehouse/CodeListener { // app.cash.redwood.treehouse/CodeListener|null[0] - constructor () // app.cash.redwood.treehouse/CodeListener.|(){}[0] - open fun onCodeDetached(app.cash.redwood.treehouse/TreehouseApp<*>, app.cash.redwood.treehouse/TreehouseView<*>, kotlin/Throwable?) // app.cash.redwood.treehouse/CodeListener.onCodeDetached|onCodeDetached(app.cash.redwood.treehouse.TreehouseApp<*>;app.cash.redwood.treehouse.TreehouseView<*>;kotlin.Throwable?){}[0] - open fun onCodeLoaded(app.cash.redwood.treehouse/TreehouseApp<*>, app.cash.redwood.treehouse/TreehouseView<*>, kotlin/Boolean) // app.cash.redwood.treehouse/CodeListener.onCodeLoaded|onCodeLoaded(app.cash.redwood.treehouse.TreehouseApp<*>;app.cash.redwood.treehouse.TreehouseView<*>;kotlin.Boolean){}[0] - open fun onInitialCodeLoading(app.cash.redwood.treehouse/TreehouseApp<*>, app.cash.redwood.treehouse/TreehouseView<*>) // app.cash.redwood.treehouse/CodeListener.onInitialCodeLoading|onInitialCodeLoading(app.cash.redwood.treehouse.TreehouseApp<*>;app.cash.redwood.treehouse.TreehouseView<*>){}[0] + final fun superviewChanged() // app.cash.redwood.treehouse/TreehouseUIView.superviewChanged|superviewChanged(){}[0] } open class app.cash.redwood.treehouse/EventListener { // app.cash.redwood.treehouse/EventListener|null[0] @@ -185,6 +180,6 @@ open class app.cash.redwood.treehouse/EventListener { // app.cash.redwood.treeho } } -final fun <#A: app.cash.redwood.treehouse/AppService, #B: kotlin/Any> (app.cash.redwood.treehouse/TreehouseContentSource<#A>).app.cash.redwood.treehouse/bindWhenReady(app.cash.redwood.treehouse/TreehouseView<#B>, app.cash.redwood.treehouse/TreehouseApp<#A>, app.cash.redwood.treehouse/CodeListener = ...): okio/Closeable // app.cash.redwood.treehouse/bindWhenReady|bindWhenReady@app.cash.redwood.treehouse.TreehouseContentSource<0:0>(app.cash.redwood.treehouse.TreehouseView<0:1>;app.cash.redwood.treehouse.TreehouseApp<0:0>;app.cash.redwood.treehouse.CodeListener){0§;1§}[0] +final fun <#A: app.cash.redwood.treehouse/AppService, #B: kotlin/Any> (app.cash.redwood.treehouse/TreehouseContentSource<#A>).app.cash.redwood.treehouse/bindWhenReady(app.cash.redwood.treehouse/TreehouseView<#B>, app.cash.redwood.treehouse/TreehouseApp<#A>): okio/Closeable // app.cash.redwood.treehouse/bindWhenReady|bindWhenReady@app.cash.redwood.treehouse.TreehouseContentSource<0:0>(app.cash.redwood.treehouse.TreehouseView<0:1>;app.cash.redwood.treehouse.TreehouseApp<0:0>){0§;1§}[0] final fun <#A: kotlin/Any> (app.cash.redwood.treehouse/Content).app.cash.redwood.treehouse/bindWhenReady(app.cash.redwood.treehouse/TreehouseView<#A>): okio/Closeable // app.cash.redwood.treehouse/bindWhenReady|bindWhenReady@app.cash.redwood.treehouse.Content(app.cash.redwood.treehouse.TreehouseView<0:0>){0§}[0] final fun app.cash.redwood.treehouse/TreehouseAppFactory(app.cash.zipline.loader/ZiplineHttpClient, app.cash.zipline.loader/ManifestVerifier, okio/FileSystem? = ..., okio/Path? = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/Int = ..., app.cash.zipline.loader/LoaderEventListener = ..., app.cash.redwood.treehouse/StateStore = ..., app.cash.redwood.leaks/LeakDetector = ...): app.cash.redwood.treehouse/TreehouseApp.Factory // app.cash.redwood.treehouse/TreehouseAppFactory|TreehouseAppFactory(app.cash.zipline.loader.ZiplineHttpClient;app.cash.zipline.loader.ManifestVerifier;okio.FileSystem?;okio.Path?;kotlin.String;kotlin.Long;kotlin.Int;app.cash.zipline.loader.LoaderEventListener;app.cash.redwood.treehouse.StateStore;app.cash.redwood.leaks.LeakDetector){}[0] diff --git a/redwood-treehouse-host/build.gradle b/redwood-treehouse-host/build.gradle index 1dd036d895..72fc686427 100644 --- a/redwood-treehouse-host/build.gradle +++ b/redwood-treehouse-host/build.gradle @@ -40,6 +40,7 @@ kotlin { implementation libs.jetbrains.compose.collection implementation projects.redwoodLayoutTesting implementation projects.redwoodLazylayoutTesting + implementation projects.redwoodWidget implementation projects.testApp.schema.protocolHost implementation projects.testApp.schema.testing } diff --git a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseLayout.kt b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseLayout.kt index b5980011a7..112ffab2b5 100644 --- a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseLayout.kt +++ b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseLayout.kt @@ -20,11 +20,12 @@ import android.content.Context import android.os.Parcel import android.os.Parcelable import android.view.View -import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.activity.OnBackPressedDispatcher as AndroidOnBackPressedDispatcher import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener import app.cash.redwood.treehouse.TreehouseView.WidgetSystem import app.cash.redwood.widget.RedwoodLayout +import app.cash.redwood.widget.RedwoodView +import app.cash.redwood.widget.ViewRoot import app.cash.treehouse.host.R import java.util.UUID @@ -33,7 +34,8 @@ public class TreehouseLayout( context: Context, override val widgetSystem: WidgetSystem, androidOnBackPressedDispatcher: AndroidOnBackPressedDispatcher, -) : RedwoodLayout(context, androidOnBackPressedDispatcher), + root: RedwoodView.Root = ViewRoot(context), +) : RedwoodLayout(context, androidOnBackPressedDispatcher, root), TreehouseView { override var readyForContentChangeListener: ReadyForContentChangeListener? = null set(value) { @@ -70,9 +72,6 @@ public class TreehouseLayout( readyForContentChangeListener?.onReadyForContentChanged(this) } - override fun generateDefaultLayoutParams(): LayoutParams = - LayoutParams(MATCH_PARENT, MATCH_PARENT) - override fun onSaveInstanceState(): Parcelable? { val id = UUID.randomUUID().toString() val superState = super.onSaveInstanceState() diff --git a/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt b/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt index c527d35a70..9e82869911 100644 --- a/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt +++ b/redwood-treehouse-host/src/androidUnitTest/kotlin/app/cash/redwood/treehouse/TreehouseLayoutTest.kt @@ -29,11 +29,9 @@ import app.cash.redwood.treehouse.TreehouseView.WidgetSystem import app.cash.redwood.ui.Density import app.cash.redwood.ui.LayoutDirection import app.cash.redwood.ui.Margin -import app.cash.redwood.widget.ViewGroupChildren import app.cash.redwood.widget.Widget import app.cash.turbine.test import assertk.assertThat -import assertk.assertions.hasSize import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isSameInstanceAs @@ -53,11 +51,12 @@ class TreehouseLayoutTest { @Test fun widgetsAddChildViews() { val layout = TreehouseLayout(activity, throwingWidgetSystem, activity.onBackPressedDispatcher) + val rootView = layout.root.value as ViewGroup val view = View(activity) - layout.children.insert(0, viewWidget(view)) - assertThat(layout.childCount).isEqualTo(1) - assertThat(layout.getChildAt(0)).isSameInstanceAs(view) + layout.root.children.insert(0, viewWidget(view)) + assertThat(rootView.childCount).isEqualTo(1) + assertThat(rootView.getChildAt(0)).isSameInstanceAs(view) } @Test fun attachAndDetachSendsStateChange() { @@ -75,29 +74,6 @@ class TreehouseLayoutTest { assertThat(listener.count).isEqualTo(2) } - @Test fun resetClearsUntrackedChildren() { - val layout = TreehouseLayout(activity, throwingWidgetSystem, activity.onBackPressedDispatcher) - - layout.addView(View(activity)) - assertThat(layout.childCount).isEqualTo(1) - - layout.reset() - assertThat(layout.childCount).isEqualTo(0) - } - - @Test fun resetClearsTrackedWidgets() { - val layout = TreehouseLayout(activity, throwingWidgetSystem, activity.onBackPressedDispatcher) - - // Needed to access internal state which cannot be reasonably observed through the public API. - val children = layout.children as ViewGroupChildren - - children.insert(0, viewWidget(View(activity))) - assertThat(children.widgets).hasSize(1) - - layout.reset() - assertThat(children.widgets).hasSize(0) - } - @Test fun uiConfigurationReflectsInitialUiMode() { val newConfig = Configuration(activity.resources.configuration) newConfig.uiMode = (newConfig.uiMode and UI_MODE_NIGHT_MASK.inv()) or UI_MODE_NIGHT_YES diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt index 9fe1241b63..71c44b7ba5 100644 --- a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt @@ -15,6 +15,7 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.testing.WidgetValue import app.cash.redwood.treehouse.leaks.LeakWatcher import app.cash.zipline.Zipline import assertk.assertThat @@ -45,7 +46,7 @@ class LeaksTest { assertThat(textInputValue.text).isEqualTo("what would you like to see?") val widgetLeakWatcher = LeakWatcher { - view.children.widgets.single() + view.root.children.widgets.single() } // While the widget is in the UI, it's expected to be in a reference cycle. @@ -158,7 +159,6 @@ class LeaksTest { val content = treehouseApp.createContent( source = contentSource, - codeListener = FakeCodeListener(tester.eventLog), ) return@run content to LeakWatcher { contentSource } @@ -166,12 +166,12 @@ class LeaksTest { // After we bind the content, it'll be in a retain cycle. content.bind(view) - tester.eventLog.takeEvent("onCodeLoaded(test_app, view, initial = true)", skipOthers = true) + tester.eventLog.takeEvent("codeListener.onCodeLoaded(1)", skipOthers = true) contentSourceLeakWatcher.assertObjectInReferenceCycle() // Unbind it, and it's no longer retained. content.unbind() - tester.eventLog.takeEvent("onCodeDetached(test_app, view, null)", skipOthers = true) + tester.eventLog.takeEvent("codeListener.onCodeDetached(null)", skipOthers = true) treehouseApp.dispatchers.awaitLaunchedTasks() contentSourceLeakWatcher.assertNotLeaked() @@ -179,31 +179,30 @@ class LeaksTest { } @Test - fun codeListenerNotLeaked() = runTest { + fun redwoodViewNotLeaked() = runTest { val tester = TreehouseTester(this) val treehouseApp = tester.loadApp() - val view = tester.view() + var view: TreehouseView? = tester.view() - var codeListener: CodeListener? = RetainEverythingCodeListener(tester.eventLog) val content = treehouseApp.createContent( source = { app -> app.launchForTester() }, - codeListener = codeListener!!, ) - val codeListenerLeakWatcher = LeakWatcher { codeListener } + val viewLeakWatcher = LeakWatcher { + view + } - // Stop referencing the CodeListener from our test harness. - codeListener = null + // One content is bound, the view is in a reference cycle. + content.bind(view!!) + tester.eventLog.takeEvent("codeListener.onCodeLoaded(1)", skipOthers = true) + viewLeakWatcher.assertObjectInReferenceCycle() - // One a view is bound, the code listener is in a reference cycle. - content.bind(view) - tester.eventLog.takeEvent("onCodeLoaded", skipOthers = true) - codeListenerLeakWatcher.assertObjectInReferenceCycle() + // Stop referencing the view from our test harness. + view = null - // When the view is unbound, the code listener is no longer reachable. + // When content is unbound, the view is no longer reachable. content.unbind() - tester.eventLog.takeEvent("onCodeDetached", skipOthers = true) treehouseApp.dispatchers.awaitLaunchedTasks() - codeListenerLeakWatcher.assertNotLeaked() + viewLeakWatcher.assertNotLeaked() treehouseApp.stop() } @@ -250,6 +249,38 @@ class LeaksTest { assertThat(treehouseTester.openTreehouseDispatchersCount).isEqualTo(0) } + /** + * Confirm we don't hold an object beyond its required lifecycle. Confirming no cycles exist is + * more strict than we require for this object: we only need the content to be unreachable. + */ + @Test + fun contentNotHeldAfterUnbind() = runTest { + val tester = TreehouseTester(this) + val treehouseApp = tester.loadApp() + val view = tester.view() + + var content: Content? = tester.content(treehouseApp) + val contentLeakWatcher = LeakWatcher { + content + } + + // After we bind the content, it'll be in a retain cycle. + content!!.bind(view) + tester.eventLog.takeEvent("codeListener.onCodeLoaded(1)", skipOthers = true) + contentLeakWatcher.assertObjectInReferenceCycle() + + // Unbind it and stop referencing it in our test harness. + content.unbind() + content = null + + // The content is no longer retained. + tester.eventLog.takeEvent("codeListener.onCodeDetached(null)", skipOthers = true) + treehouseApp.dispatchers.awaitLaunchedTasks() + contentLeakWatcher.assertNotLeaked() + + treehouseApp.stop() + } + /** * This is unfortunate. Some cleanup functions launch jobs on another dispatcher and we don't have * a natural way to wait for those jobs to complete. So we launch empty jobs on each dispatcher, diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/RetainEverythingCodeListener.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/RetainEverythingCodeListener.kt deleted file mode 100644 index e14328bd0f..0000000000 --- a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/RetainEverythingCodeListener.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (C) 2024 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package app.cash.redwood.treehouse - -class RetainEverythingCodeListener( - private val eventLog: EventLog, -) : CodeListener() { - private var app: TreehouseApp<*>? = null - private var view: TreehouseView<*>? = null - - override fun onInitialCodeLoading(app: TreehouseApp<*>, view: TreehouseView<*>) { - this.app = app - this.view = view - } - - override fun onCodeLoaded(app: TreehouseApp<*>, view: TreehouseView<*>, initial: Boolean) { - this.app = app - this.view = view - eventLog += "onCodeLoaded" - } - - override fun onCodeDetached(app: TreehouseApp<*>, view: TreehouseView<*>, exception: Throwable?) { - this.app = app - this.view = view - eventLog += "onCodeDetached" - } -} diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt index e783f39a06..7b7ec88e69 100644 --- a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt @@ -148,13 +148,13 @@ internal class TreehouseTester( fun content(treehouseApp: TreehouseApp): Content { return treehouseApp.createContent( source = { app -> app.launchForTester() }, - codeListener = FakeCodeListener(eventLog), ) } fun view(): FakeTreehouseView { return FakeTreehouseView( name = "view", + eventLog = eventLog, onBackPressedDispatcher = FakeOnBackPressedDispatcher(eventLog), ) } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt index 66802b3380..dbae374355 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ChangeListRenderer.kt @@ -40,11 +40,12 @@ public class ChangeListRenderer( view: TreehouseView, changeList: SnapshotChangeList, ) { - view.reset() + view.root.children.remove(0, view.root.children.widgets.size) + val hostAdapter = HostProtocolAdapter( // Use latest host version as the guest version to avoid any compatibility behavior. guestVersion = hostRedwoodVersion, - container = view.children, + container = view.root.children, factory = view.widgetSystem.widgetFactory( json, ProtocolMismatchHandler.Throwing, diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt index 0053c55f07..1c26613972 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.job import kotlinx.coroutines.launch +import okio.Closeable /** * Manages loading and hot-reloading a series of code sessions. @@ -51,8 +52,9 @@ import kotlinx.coroutines.launch internal abstract class CodeHost( private val dispatchers: TreehouseDispatchers, private val appScope: CoroutineScope, + eventListenerFactory: EventListener.Factory, val stateStore: StateStore, -) { +) : Closeable { /** Contents that this app is currently responsible for. */ private val listeners = mutableListOf>() @@ -85,12 +87,15 @@ internal abstract class CodeHost( val codeSession: CodeSession? get() = state.codeSession + private var eventListenerFactory: EventListener.Factory? = eventListenerFactory + /** Returns a flow that emits a new [CodeSession] each time we should load fresh code. */ abstract fun codeUpdatesFlow( eventListenerFactory: EventListener.Factory, ): Flow> - fun start(eventListenerFactory: EventListener.Factory) { + fun start() { + val eventListenerFactory = this.eventListenerFactory ?: error("closed") dispatchers.checkUi() val previous = state @@ -118,7 +123,8 @@ internal abstract class CodeHost( mutableZipline.value = null } - fun restart(eventListenerFactory: EventListener.Factory) { + fun restart() { + val eventListenerFactory = this.eventListenerFactory ?: error("closed") dispatchers.checkUi() val previous = state @@ -144,6 +150,10 @@ internal abstract class CodeHost( listeners -= listener } + override fun close() { + eventListenerFactory = null // Break a reference cycle. + } + private fun newCodeUpdatesScope() = CoroutineScope(SupervisorJob(appScope.coroutineContext.job)) diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt deleted file mode 100644 index 542641dc76..0000000000 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (C) 2023 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package app.cash.redwood.treehouse - -import kotlin.native.ObjCName - -@ObjCName("CodeListener", exact = true) -public open class CodeListener { - /** - * Invoked when the initial code is still loading. This can be used to signal a loading state - * in the UI before there is anything to display. - */ - public open fun onInitialCodeLoading( - app: TreehouseApp<*>, - view: TreehouseView<*>, - ) { - } - - /** - * Invoked each time new code is loaded. This is called after the view's old children have - * been cleared but before the children of the new code have been added. - * - * @param initial true if this is the first code loaded for this view's current content. - */ - public open fun onCodeLoaded( - app: TreehouseApp<*>, - view: TreehouseView<*>, - initial: Boolean, - ) { - } - - /** - * Invoked when the application powering [view] stops sending updates. This is triggered by: - * - * * the UI no longer needing code to drive it, perhaps because it's detached from the screen - * * the code being hot-reloaded - * * the code failing with an exception - * - * If it is failing due to a failure, [exception] will be non-null and this function should - * display an error UI. Typical implementations call [TreehouseView.reset] and display an error - * placeholder. Development builds may show more diagnostic information than production builds. - * - * When a Treehouse app fails, its current Zipline instance is canceled so no further code will - * execute. A new Zipline will start when new code available. - * - * This condition is not permanent! If so, [onCodeLoaded] will be called. - */ - public open fun onCodeDetached( - app: TreehouseApp<*>, - view: TreehouseView<*>, - exception: Throwable?, - ) { - } -} diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ContentBinding.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ContentBinding.kt index dc58761173..48f50256a6 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ContentBinding.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ContentBinding.kt @@ -48,8 +48,7 @@ public fun Content.bindWhenReady(view: TreehouseView): Closeable { public fun TreehouseContentSource.bindWhenReady( view: TreehouseView, app: TreehouseApp, - codeListener: CodeListener = CodeListener(), ): Closeable { - val content = app.createContent(this, codeListener) + val content = app.createContent(this) return content.bindWhenReady(view) } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealCodeEventPublisher.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealCodeEventPublisher.kt deleted file mode 100644 index 5d733d296c..0000000000 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealCodeEventPublisher.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2023 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package app.cash.redwood.treehouse - -internal class RealCodeEventPublisher( - private val codeListener: CodeListener, - private val app: TreehouseApp<*>, -) : CodeEventPublisher { - override fun onInitialCodeLoading(view: TreehouseView<*>) { - return codeListener.onInitialCodeLoading(app, view) - } - - override fun onCodeLoaded(view: TreehouseView<*>, initial: Boolean) { - return codeListener.onCodeLoaded(app, view, initial) - } - - override fun onCodeDetached(view: TreehouseView<*>, exception: Throwable?) { - return codeListener.onCodeDetached(app, view, exception) - } -} diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt index a8ced5001d..477fd3f8d2 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt @@ -49,6 +49,7 @@ internal class RealTreehouseApp private constructor( dispatchers = dispatchers, appScope = appScope, stateStore = factory.stateStore, + eventListenerFactory = eventListenerFactory, ) { override fun codeUpdatesFlow( eventListenerFactory: EventListener.Factory, @@ -74,24 +75,19 @@ internal class RealTreehouseApp private constructor( override val zipline: StateFlow get() = codeHost.zipline - override fun createContent( - source: TreehouseContentSource, - codeListener: CodeListener, - ): Content { + override fun createContent(source: TreehouseContentSource): Content { start() return TreehouseAppContent( codeHost = codeHost, dispatchers = dispatchers, - codeEventPublisher = RealCodeEventPublisher(codeListener, this), source = source, leakDetector = leakDetector, ) } override fun start() { - val eventListenerFactory = eventListenerFactory ?: error("closed") - codeHost.start(eventListenerFactory) + codeHost.start() } override fun stop() { @@ -99,8 +95,7 @@ internal class RealTreehouseApp private constructor( } override fun restart() { - val eventListenerFactory = eventListenerFactory ?: error("closed") - codeHost.restart(eventListenerFactory) + codeHost.restart() } /** @@ -174,6 +169,7 @@ internal class RealTreehouseApp private constructor( closed = true spec = null + codeHost.close() eventListenerFactory?.close() eventListenerFactory = null stop() diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt index 5a9a2a4f49..9b9728cc13 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt @@ -65,7 +65,6 @@ public abstract class TreehouseApp : AutoCloseable { */ public abstract fun createContent( source: TreehouseContentSource, - codeListener: CodeListener = CodeListener(), ): Content /** diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt index e1cc4001f3..e6d3a0974f 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt @@ -66,11 +66,14 @@ private sealed interface ViewState { } private sealed interface CodeState { + val loadCount: Int + class Idle( - val isInitialLaunch: Boolean, + override val loadCount: Int = 0, ) : CodeState class Running( + override val loadCount: Int = 0, val viewContentCodeBinding: ViewContentCodeBinding, val changesAwaitingInitViewSize: Int = 0, val deliveredChangeCount: Int = 0, @@ -83,14 +86,13 @@ private sealed interface CodeState { internal class TreehouseAppContent( private val codeHost: CodeHost, private val dispatchers: TreehouseDispatchers, - private val codeEventPublisher: CodeEventPublisher, private val source: TreehouseContentSource, private val leakDetector: LeakDetector, ) : Content, CodeHost.Listener, CodeSession.Listener { private val stateFlow = MutableStateFlow>( - State(ViewState.None, CodeState.Idle(isInitialLaunch = true)), + State(ViewState.None, CodeState.Idle()), ) override fun preload( @@ -100,7 +102,10 @@ internal class TreehouseAppContent( dispatchers.checkUi() val previousState = stateFlow.value - if (previousState.viewState == ViewState.Preloading(onBackPressedDispatcher, uiConfiguration)) return // Idempotent. + if (previousState.viewState == ViewState.Preloading(onBackPressedDispatcher, uiConfiguration)) { + return // Idempotent. + } + check(previousState.viewState is ViewState.None) val nextViewState = ViewState.Preloading(onBackPressedDispatcher, uiConfiguration) @@ -109,10 +114,12 @@ internal class TreehouseAppContent( val codeSession = codeHost.codeSession val nextCodeState = when { previousState.codeState is CodeState.Idle && codeSession != null -> { + val newLoadCount = previousState.codeState.loadCount + 1 CodeState.Running( - startViewCodeContentBinding( + loadCount = newLoadCount, + viewContentCodeBinding = startViewCodeContentBinding( codeSession = codeSession, - isInitialLaunch = true, + loadCount = newLoadCount, onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = uiConfiguration, ), @@ -133,6 +140,8 @@ internal class TreehouseAppContent( if (stateFlow.value.viewState == ViewState.Bound(view)) return // Idempotent. + view.root.restart(codeHost::restart) + preload(view.onBackPressedDispatcher, view.uiConfiguration) val previousState = stateFlow.value @@ -146,7 +155,12 @@ internal class TreehouseAppContent( // Make sure we're showing something in the view; either loaded code or a spinner to show that // code is coming. when (nextCodeState) { - is CodeState.Idle -> codeEventPublisher.onInitialCodeLoading(view) + is CodeState.Idle -> { + view.root.contentState( + loadCount = nextCodeState.loadCount, + attached = false, + ) + } is CodeState.Running -> nextCodeState.viewContentCodeBinding.initView(view) } @@ -168,16 +182,22 @@ internal class TreehouseAppContent( val previousState = stateFlow.value val previousViewState = previousState.viewState + val previousCodeState = previousState.codeState if (previousViewState is ViewState.None) return // Idempotent. + if (previousViewState is ViewState.Bound) { + previousViewState.view.root.restart(null) // Break a reference cycle. + } val nextViewState = ViewState.None - val nextCodeState = CodeState.Idle(isInitialLaunch = true) + val nextCodeState = CodeState.Idle( + loadCount = previousCodeState.loadCount, + ) // Cancel the code if necessary. codeHost.removeListener(this) - if (previousState.codeState is CodeState.Running) { - val binding = previousState.codeState.viewContentCodeBinding + if (previousCodeState is CodeState.Running) { + val binding = previousCodeState.viewContentCodeBinding binding.cancel(null) binding.codeSession.removeListener(this) } @@ -204,10 +224,12 @@ internal class TreehouseAppContent( else -> error("unexpected receiveCodeSession with no view bound and no preload") } + val newLoadCount = previousCodeState.loadCount + 1 val nextCodeState = CodeState.Running( - startViewCodeContentBinding( + loadCount = newLoadCount, + viewContentCodeBinding = startViewCodeContentBinding( codeSession = next, - isInitialLaunch = (previousCodeState as? CodeState.Idle)?.isInitialLaunch == true, + loadCount = newLoadCount, onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = uiConfiguration, ), @@ -255,14 +277,16 @@ internal class TreehouseAppContent( binding.cancel(exception) binding.codeSession.removeListener(this) - val nextCodeState = CodeState.Idle(isInitialLaunch = false) + val nextCodeState = CodeState.Idle( + loadCount = previousCodeState.loadCount, + ) stateFlow.value = State(viewState, nextCodeState) } /** This function may only be invoked on [TreehouseDispatchers.ui]. */ private fun startViewCodeContentBinding( codeSession: CodeSession, - isInitialLaunch: Boolean, + loadCount: Int, onBackPressedDispatcher: OnBackPressedDispatcher, firstUiConfiguration: StateFlow, ): ViewContentCodeBinding { @@ -274,10 +298,9 @@ internal class TreehouseAppContent( dispatchers = dispatchers, eventPublisher = codeSession.eventPublisher, contentSource = source, - codeEventPublisher = codeEventPublisher, stateFlow = stateFlow, codeSession = codeSession, - isInitialLaunch = isInitialLaunch, + loadCount = loadCount, onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = firstUiConfiguration, leakDetector = leakDetector, @@ -304,10 +327,9 @@ private class ViewContentCodeBinding( val dispatchers: TreehouseDispatchers, val eventPublisher: EventPublisher, contentSource: TreehouseContentSource, - val codeEventPublisher: CodeEventPublisher, val stateFlow: MutableStateFlow>, val codeSession: CodeSession, - private val isInitialLaunch: Boolean, + private val loadCount: Int, private val onBackPressedDispatcher: OnBackPressedDispatcher, firstUiConfiguration: StateFlow, private val leakDetector: LeakDetector, @@ -402,7 +424,7 @@ private class ViewContentCodeBinding( @Suppress("UNCHECKED_CAST") // We don't have a type parameter for the widget type. hostAdapter = HostProtocolAdapter( guestVersion = codeSession.guestProtocolVersion, - container = view.children as Widget.Children, + container = view.root.children as Widget.Children, factory = view.widgetSystem.widgetFactory( json = codeSession.json, protocolMismatchHandler = eventPublisher.widgetProtocolMismatchHandler, @@ -414,8 +436,11 @@ private class ViewContentCodeBinding( } if (deliveredChangeCount++ == 0) { - view.reset() - codeEventPublisher.onCodeLoaded(view, isInitialLaunch) + view.root.children.remove(0, view.root.children.widgets.size) + view.root.contentState( + loadCount = loadCount, + attached = true, + ) } updateChangeCount() @@ -512,7 +537,11 @@ private class ViewContentCodeBinding( canceled = true viewOrNull?.let { view -> - codeEventPublisher.onCodeDetached(view, exception) + view.root.contentState( + loadCount = loadCount, + attached = false, + uncaughtException = exception, + ) view.saveCallback = null } viewOrNull = null diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt index f2d2e58f3d..2f10d862e1 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CodeHostTest.kt @@ -40,7 +40,6 @@ class CodeHostTest { dispatchers = dispatchers, appScope = appScope, ) - private val codeEventPublisher = FakeCodeEventPublisher(eventLog) private val onBackPressedDispatcher = FakeOnBackPressedDispatcher(eventLog) @AfterTest @@ -55,9 +54,9 @@ class CodeHostTest { val content = treehouseAppContent() val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates1.collect()") codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") @@ -65,7 +64,7 @@ class CodeHostTest { codeHost.stop() eventLog.takeEvent("codeHostUpdates1.close()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") @@ -78,9 +77,9 @@ class CodeHostTest { val content = treehouseAppContent() val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") - codeHost.restart(EventListener.NONE) + codeHost.restart() eventLog.takeEvent("codeHostUpdates1.collect()") codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") @@ -88,7 +87,7 @@ class CodeHostTest { codeHost.stop() eventLog.takeEvent("codeHostUpdates1.close()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") @@ -101,27 +100,27 @@ class CodeHostTest { val content = treehouseAppContent() val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates1.collect()") codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeHost.stop() eventLog.takeEvent("codeHostUpdates1.close()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates2.collect()") codeHost.startCodeSession("codeSessionB") eventLog.takeEvent("codeSessionB.start()") eventLog.takeEvent("codeSessionB.app.uis[0].start()") codeHost.stop() eventLog.takeEvent("codeHostUpdates2.close()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionB.app.uis[0].close()") eventLog.takeEvent("codeSessionB.stop()") @@ -134,19 +133,19 @@ class CodeHostTest { val content = treehouseAppContent() val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates1.collect()") val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeSessionA.handleUncaughtException(Exception("boom!")) - eventLog.takeEvent("codeListener.onCodeDetached(view1, kotlin.Exception: boom!)") + eventLog.takeEvent("codeListener.onCodeDetached(kotlin.Exception: boom!)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") - codeHost.restart(EventListener.NONE) + codeHost.restart() eventLog.takeEvent("codeHostUpdates1.close()") eventLog.takeEvent("codeHostUpdates2.collect()") codeHost.startCodeSession("codeSessionB") @@ -154,7 +153,7 @@ class CodeHostTest { eventLog.takeEvent("codeSessionB.app.uis[0].start()") codeHost.stop() eventLog.takeEvent("codeHostUpdates2.close()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionB.app.uis[0].close()") eventLog.takeEvent("codeSessionB.stop()") @@ -167,15 +166,15 @@ class CodeHostTest { val content = treehouseAppContent() val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates1.collect()") val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeSessionA.handleUncaughtException(Exception("boom!")) - eventLog.takeEvent("codeListener.onCodeDetached(view1, kotlin.Exception: boom!)") + eventLog.takeEvent("codeListener.onCodeDetached(kotlin.Exception: boom!)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") @@ -184,7 +183,7 @@ class CodeHostTest { eventLog.takeEvent("codeSessionB.app.uis[0].start()") codeHost.stop() eventLog.takeEvent("codeHostUpdates1.close()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionB.app.uis[0].close()") eventLog.takeEvent("codeSessionB.stop()") @@ -197,15 +196,15 @@ class CodeHostTest { val content = treehouseAppContent() val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates1.collect()") val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeSessionA.handleUncaughtException(Exception("boom!")) - eventLog.takeEvent("codeListener.onCodeDetached(view1, kotlin.Exception: boom!)") + eventLog.takeEvent("codeListener.onCodeDetached(kotlin.Exception: boom!)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") @@ -221,13 +220,13 @@ class CodeHostTest { val content = treehouseAppContent() val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates1.collect()") eventLog.assertNoEvents() - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.assertNoEvents() codeHost.startCodeSession("codeSessionA") @@ -235,7 +234,7 @@ class CodeHostTest { eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeHost.stop() eventLog.takeEvent("codeHostUpdates1.close()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") @@ -248,20 +247,20 @@ class CodeHostTest { val content = treehouseAppContent() val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates1.collect()") codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.assertNoEvents() codeHost.stop() eventLog.takeEvent("codeHostUpdates1.close()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") @@ -274,9 +273,9 @@ class CodeHostTest { val content = treehouseAppContent() val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates1.collect()") codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") @@ -284,7 +283,7 @@ class CodeHostTest { codeHost.stop() eventLog.takeEvent("codeHostUpdates1.close()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") @@ -297,13 +296,16 @@ class CodeHostTest { return TreehouseAppContent( codeHost = codeHost, dispatchers = dispatchers, - codeEventPublisher = codeEventPublisher, source = { app -> app.newUi() }, leakDetector = LeakDetector.none(), ) } private fun treehouseView(name: String): FakeTreehouseView { - return FakeTreehouseView(name, onBackPressedDispatcher) + return FakeTreehouseView( + name = name, + eventLog = eventLog, + onBackPressedDispatcher = onBackPressedDispatcher, + ) } } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeEventPublisher.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeEventPublisher.kt deleted file mode 100644 index 9c7f0cb973..0000000000 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeEventPublisher.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (C) 2023 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package app.cash.redwood.treehouse - -class FakeCodeEventPublisher( - private val eventLog: EventLog, -) : CodeEventPublisher { - override fun onInitialCodeLoading(view: TreehouseView<*>) { - eventLog += "codeListener.onInitialCodeLoading($view)" - } - - override fun onCodeLoaded(view: TreehouseView<*>, initial: Boolean) { - eventLog += "codeListener.onCodeLoaded($view, initial = $initial)" - } - - override fun onCodeDetached(view: TreehouseView<*>, exception: Throwable?) { - // Canonicalize "java.lang.Exception(boom!)" to "kotlin.Exception(boom!)". - val exceptionString = exception?.toString()?.replace("java.lang.", "kotlin.") - eventLog += "codeListener.onCodeDetached($view, $exceptionString)" - } -} diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt index d3ef3e29dd..71ee952809 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt @@ -29,6 +29,7 @@ internal class FakeCodeHost( dispatchers = dispatchers, appScope = appScope, stateStore = MemoryStateStore(), + eventListenerFactory = EventListener.NONE, ) { private var codeSessions: Channel>? = null private var nextCollectId = 1 diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt deleted file mode 100644 index 7a025806c9..0000000000 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) 2024 Square, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package app.cash.redwood.treehouse - -class FakeCodeListener( - private val eventLog: EventLog, -) : CodeListener() { - override fun onInitialCodeLoading(app: TreehouseApp<*>, view: TreehouseView<*>) { - eventLog += "onInitialCodeLoading(${app.name}, $view)" - } - - override fun onCodeLoaded(app: TreehouseApp<*>, view: TreehouseView<*>, initial: Boolean) { - eventLog += "onCodeLoaded(${app.name}, $view, initial = $initial)" - } - - override fun onCodeDetached(app: TreehouseApp<*>, view: TreehouseView<*>, exception: Throwable?) { - eventLog += "onCodeDetached(${app.name}, $view, $exception)" - } -} diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeRoot.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeRoot.kt new file mode 100644 index 0000000000..388cb5ed54 --- /dev/null +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeRoot.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse + +import app.cash.redwood.Modifier +import app.cash.redwood.testing.WidgetValue +import app.cash.redwood.widget.MutableListChildren +import app.cash.redwood.widget.RedwoodView +import app.cash.redwood.widget.Widget + +class FakeRoot( + private val eventLog: EventLog, +) : RedwoodView.Root { + private val childrenDelegate = MutableListChildren() + + /** Keep views (but not widgets) after [Widget.Children.detach]. */ + private var viewsAfterDetach: List? = null + + val views: List + get() = viewsAfterDetach ?: childrenDelegate.widgets.map { it.value } + + override val children = object : Widget.Children by childrenDelegate { + override fun detach() { + viewsAfterDetach = childrenDelegate.widgets.map { it.value } + childrenDelegate.detach() + } + } + + override val value: WidgetValue + get() = error("unexpected call") + override var modifier: Modifier = Modifier + + override fun contentState(loadCount: Int, attached: Boolean, uncaughtException: Throwable?) { + // Canonicalize "java.lang.Exception(boom!)" to "kotlin.Exception(boom!)". + val exceptionString = uncaughtException?.toString()?.replace("java.lang.", "kotlin.") + + // TODO(jwilson): this is a backwards-compatibility shim. Emit a simpler event. + eventLog += when { + loadCount == 0 && !attached -> "codeListener.onInitialCodeLoading()" + attached -> "codeListener.onCodeLoaded($loadCount)" + else -> "codeListener.onCodeDetached($exceptionString)" + } + } + + override fun restart(restart: (() -> Unit)?) { + } +} diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt index fd79a5058e..087137d5fa 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeTreehouseView.kt @@ -22,7 +22,6 @@ import app.cash.redwood.testing.WidgetValue import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.UiConfiguration -import app.cash.redwood.widget.MutableListChildren import app.cash.redwood.widget.SavedStateRegistry import app.cash.redwood.widget.Widget import com.example.redwood.testapp.protocol.host.TestSchemaProtocolFactory @@ -39,14 +38,15 @@ import kotlinx.coroutines.flow.StateFlow */ internal class FakeTreehouseView( private val name: String, + eventLog: EventLog, override val onBackPressedDispatcher: OnBackPressedDispatcher, override val uiConfiguration: StateFlow = MutableStateFlow(UiConfiguration()), ) : TreehouseView { - private val mutableListChildren = MutableListChildren() - private val mutableViews = mutableListOf() + override val root = FakeRoot(eventLog) + @Deprecated("inline this?") val views: List - get() = mutableViews + get() = root.children.widgets.map { it.value } @OptIn(RedwoodCodegenApi::class) override val widgetSystem = TreehouseView.WidgetSystem { json, protocolMismatchHandler -> @@ -72,27 +72,7 @@ internal class FakeTreehouseView( override val stateSnapshotId: StateSnapshot.Id = StateSnapshot.Id(null) - override val children = object : Widget.Children by mutableListChildren { - override fun insert(index: Int, widget: Widget) { - mutableViews.add(index, widget.value) - mutableListChildren.insert(index, widget) - } - - override fun remove(index: Int, count: Int) { - mutableViews.subList(index, index + count).clear() - mutableListChildren.remove(index, count) - } - - override fun onModifierUpdated(index: Int, widget: Widget) { - } - } - override val savedStateRegistry: SavedStateRegistry? = null - override fun reset() { - mutableViews.clear() - mutableListChildren.clear() - } - override fun toString() = name } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt index d0f6fb855c..a3ae22d026 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt @@ -53,13 +53,12 @@ class TreehouseAppContentTest { dispatchers = dispatchers, appScope = appScope, ) - private val codeEventPublisher = FakeCodeEventPublisher(eventLog) private val uiConfiguration = MutableStateFlow(UiConfiguration()) @BeforeTest fun setUp() { runBlocking { - codeHost.start(EventListener.NONE) + codeHost.start() eventLog.takeEvent("codeHostUpdates1.collect()") } } @@ -76,14 +75,14 @@ class TreehouseAppContentTest { val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeSessionA.appService.uis.single().addWidget("hello") - eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") + eventLog.takeEvent("codeListener.onCodeLoaded(1)") val buttonValue = view1.views.single() as ButtonValue assertThat(buttonValue.text).isEqualTo("hello") @@ -91,7 +90,7 @@ class TreehouseAppContentTest { eventLog.takeEvent("codeSessionA.app.uis[0].sendEvent()") content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") } @@ -112,10 +111,10 @@ class TreehouseAppContentTest { val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") + eventLog.takeEvent("codeListener.onCodeLoaded(1)") content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") } @@ -134,12 +133,12 @@ class TreehouseAppContentTest { eventLog.assertNoEvents() codeSessionA.appService.uis.single().addWidget("hello") - eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") + eventLog.takeEvent("codeListener.onCodeLoaded(1)") val buttonValue = view1.views.single() as ButtonValue assertThat(buttonValue.text).isEqualTo("hello") content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") } @@ -155,12 +154,12 @@ class TreehouseAppContentTest { eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeSessionA.appService.uis.single().addWidget("hello") - eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") + eventLog.takeEvent("codeListener.onCodeLoaded(1)") val buttonValue = view1.views.single() as ButtonValue assertThat(buttonValue.text).isEqualTo("hello") content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") } @@ -171,17 +170,17 @@ class TreehouseAppContentTest { val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") val codeSessionA = codeHost.startCodeSession("codeSessionA") codeSessionA.appService.uis.single().addWidget("helloA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") - eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") + eventLog.takeEvent("codeListener.onCodeLoaded(1)") val codeSessionB = codeHost.startCodeSession("codeSessionB") eventLog.takeEventsInAnyOrder( - "codeListener.onCodeDetached(view1, null)", + "codeListener.onCodeDetached(null)", "codeSessionA.app.uis[0].close()", "codeSessionA.stop()", "codeSessionB.start()", @@ -190,15 +189,15 @@ class TreehouseAppContentTest { // This still shows UI from codeSessionA. There's no onCodeLoaded() and no reset() until the new // code's first widget is added! - val buttonA = view1.views.single() as ButtonValue + val buttonA = view1.root.views.single() as ButtonValue assertThat(buttonA.text).isEqualTo("helloA") codeSessionB.appService.uis.single().addWidget("helloB") - eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = false)") + eventLog.takeEvent("codeListener.onCodeLoaded(1)") val buttonB = view1.views.single() as ButtonValue assertThat(buttonB.text).isEqualTo("helloB") content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionB.app.uis[0].close()") } @@ -223,7 +222,7 @@ class TreehouseAppContentTest { val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") content.unbind() eventLog.assertNoEvents() @@ -249,24 +248,24 @@ class TreehouseAppContentTest { eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeSessionA.appService.uis.single().addWidget("helloA") - eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") + eventLog.takeEvent("codeListener.onCodeLoaded(1)") val buttonA = view1.views.single() as ButtonValue assertThat(buttonA.text).isEqualTo("helloA") content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[1].start()") codeSessionA.appService.uis.last().addWidget("helloB") - eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") + eventLog.takeEvent("codeListener.onCodeLoaded(1)") val buttonB = view1.views.single() as ButtonValue assertThat(buttonB.text).isEqualTo("helloB") content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[1].close()") } @@ -293,7 +292,7 @@ class TreehouseAppContentTest { eventLog.assertNoEvents() content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") } @@ -314,7 +313,7 @@ class TreehouseAppContentTest { content.unbind() eventLog.takeEvent("onBackPressedDispatcher.callbacks[0].cancel()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") } @@ -334,7 +333,7 @@ class TreehouseAppContentTest { // When we close codeSessionA, its back handlers are released with it. eventLog.takeEventsInAnyOrder( - "codeListener.onCodeDetached(view1, null)", + "codeListener.onCodeDetached(null)", "codeSessionA.app.uis[0].close()", "onBackPressedDispatcher.callbacks[0].cancel()", "codeSessionA.stop()", @@ -344,7 +343,7 @@ class TreehouseAppContentTest { assertThat(onBackPressedDispatcher.callbacks).isEmpty() content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionB.app.uis[0].close()") } @@ -362,7 +361,7 @@ class TreehouseAppContentTest { codeSessionA.handleUncaughtException(Exception("boom!")) eventLog.takeEventsInAnyOrder( "codeSessionA.app.uis[0].close()", - "codeListener.onCodeDetached(view1, kotlin.Exception: boom!)", + "codeListener.onCodeDetached(kotlin.Exception: boom!)", "codeSessionA.stop()", ) @@ -381,14 +380,14 @@ class TreehouseAppContentTest { val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") codeHost.startCodeSession("codeSessionB") eventLog.takeEvent("codeSessionB.start()") eventLog.takeEvent("codeSessionB.app.uis[0].start()") content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionB.app.uis[0].close()") } @@ -406,7 +405,7 @@ class TreehouseAppContentTest { codeSessionA.handleUncaughtException(Exception("boom!")) eventLog.takeEventsInAnyOrder( "codeSessionA.app.uis[0].close()", - "codeListener.onCodeDetached(view1, kotlin.Exception: boom!)", + "codeListener.onCodeDetached(kotlin.Exception: boom!)", "codeSessionA.stop()", ) @@ -415,7 +414,7 @@ class TreehouseAppContentTest { eventLog.takeEvent("codeSessionB.app.uis[0].start()") content.unbind() - eventLog.takeEvent("codeListener.onCodeDetached(view1, null)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") eventLog.takeEvent("codeSessionB.app.uis[0].close()") } @@ -439,7 +438,7 @@ class TreehouseAppContentTest { val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onCodeDetached(null)") content.unbind() } @@ -450,20 +449,20 @@ class TreehouseAppContentTest { val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") codeSessionA.appService.uis.single().addWidget("hello") - eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") + eventLog.takeEvent("codeListener.onCodeLoaded(1)") codeSessionA.appService.uis.single().throwOnNextEvent("boom!") val button = view1.views.single() as ButtonValue button.onClick!!.invoke() eventLog.takeEvent("codeSessionA.app.uis[0].sendEvent()") - eventLog.takeEvent("codeListener.onCodeDetached(view1, kotlin.Exception: boom!)") + eventLog.takeEvent("codeListener.onCodeDetached(kotlin.Exception: boom!)") eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.stop()") @@ -493,7 +492,7 @@ class TreehouseAppContentTest { val view1 = treehouseView("view1") content.bind(view1) - eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") + eventLog.takeEvent("codeListener.onInitialCodeLoading()") content.bind(view1) eventLog.assertNoEvents() @@ -505,13 +504,17 @@ class TreehouseAppContentTest { return TreehouseAppContent( codeHost = codeHost, dispatchers = dispatchers, - codeEventPublisher = codeEventPublisher, source = { app -> app.newUi() }, leakDetector = LeakDetector.none(), ) } private fun treehouseView(name: String): FakeTreehouseView { - return FakeTreehouseView(name, onBackPressedDispatcher, uiConfiguration) + return FakeTreehouseView( + name = name, + eventLog = eventLog, + onBackPressedDispatcher = onBackPressedDispatcher, + uiConfiguration = uiConfiguration, + ) } } diff --git a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIView.kt b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIView.kt index 2be057dff9..e490b820b9 100644 --- a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIView.kt +++ b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIView.kt @@ -18,20 +18,14 @@ package app.cash.redwood.treehouse import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener import app.cash.redwood.treehouse.TreehouseView.WidgetSystem import app.cash.redwood.widget.RedwoodUIView -import kotlinx.cinterop.cValue -import platform.CoreGraphics.CGRectZero -import platform.UIKit.UILayoutConstraintAxisVertical -import platform.UIKit.UIStackView -import platform.UIKit.UIStackViewAlignmentFill -import platform.UIKit.UIStackViewDistributionFillEqually -import platform.UIKit.UITraitCollection +import app.cash.redwood.widget.UIViewRoot import platform.UIKit.UIView @ObjCName("TreehouseUIView", exact = true) -public class TreehouseUIView private constructor( +public class TreehouseUIView( override val widgetSystem: WidgetSystem, - view: RootUiView, -) : RedwoodUIView(view), + root: UIViewRoot, +) : RedwoodUIView(root), TreehouseView { override var saveCallback: TreehouseView.SaveCallback? = null override var stateSnapshotId: StateSnapshot.Id = StateSnapshot.Id(null) @@ -43,52 +37,11 @@ public class TreehouseUIView private constructor( } override val readyForContent: Boolean - get() = view.superview != null + get() = root.value.superview != null - public constructor(widgetSystem: WidgetSystem) : this(widgetSystem, RootUiView()) + public constructor(widgetSystem: WidgetSystem) : this(widgetSystem, UIViewRoot()) - init { - view.treehouseView = this - } - - private fun superviewChanged() { + override fun superviewChanged() { readyForContentChangeListener?.onReadyForContentChanged(this) } - - /** - * The root view is just a vertical stack. - * - * In practice we expect this to contain either zero child subviews (especially when - * newly-initialized) or one child subview, which will usually be a layout container. - * - * This could just as easily be a horizontal stack. A box would be even better, but there's no - * such built-in component and implementing it manually is difficult if we want to react to - * content resizes. - */ - private class RootUiView : UIStackView(cValue { CGRectZero }) { - lateinit var treehouseView: TreehouseUIView - - init { - this.axis = UILayoutConstraintAxisVertical - this.alignment = UIStackViewAlignmentFill // Fill horizontal. - this.distribution = UIStackViewDistributionFillEqually // Fill vertical. - } - - override fun layoutSubviews() { - super.layoutSubviews() - - // Bounds likely changed. Report new size. - treehouseView.updateUiConfiguration() - } - - override fun didMoveToSuperview() { - super.didMoveToSuperview() - treehouseView.superviewChanged() - } - - override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - treehouseView.updateUiConfiguration() - } - } } diff --git a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/LayoutTester.kt b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/LayoutTester.kt index 96231eff23..088500112d 100644 --- a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/LayoutTester.kt +++ b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/LayoutTester.kt @@ -69,9 +69,9 @@ class LayoutTester( Subject.TreehouseView -> TreehouseUIView(throwingWidgetSystem) .apply { - (this.children as UIViewChildren).insert(0, viewWidget(referenceView)) + (this.root.children as UIViewChildren).insert(0, viewWidget(referenceView)) } - .view + .root.value } } diff --git a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIViewTest.kt b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIViewTest.kt index 0d2e13abdb..aa3c521507 100644 --- a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIViewTest.kt +++ b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIViewTest.kt @@ -19,7 +19,6 @@ import app.cash.redwood.ui.Default import app.cash.redwood.ui.Density import app.cash.redwood.ui.LayoutDirection import app.cash.redwood.ui.Margin -import app.cash.redwood.widget.UIViewChildren import app.cash.turbine.test import assertk.assertThat import assertk.assertions.hasSize @@ -44,10 +43,10 @@ class TreehouseUIViewTest { val layout = TreehouseUIView(throwingWidgetSystem) val view = UIView() - layout.children.insert(0, viewWidget(view)) - assertThat(layout.view.subviews).hasSize(1) + layout.root.children.insert(0, viewWidget(view)) + assertThat(layout.root.value.subviews).hasSize(1) // For some reason `assertSame` fails on these references. - assertThat(layout.view.subviews[0].objcPtr()).isEqualTo(view.objcPtr()) + assertThat(layout.root.value.subviews[0].objcPtr()).isEqualTo(view.objcPtr()) } @Test fun attachAndDetachSendsStateChange() { @@ -58,43 +57,20 @@ class TreehouseUIViewTest { layout.readyForContentChangeListener = listener assertThat(listener.count).isEqualTo(0) - parent.addSubview(layout.view) + parent.addSubview(layout.root.value) assertThat(listener.count).isEqualTo(1) - layout.view.removeFromSuperview() + layout.root.value.removeFromSuperview() assertThat(listener.count).isEqualTo(2) } - @Test fun resetClearsUntrackedChildren() { - val layout = TreehouseUIView(throwingWidgetSystem) - - layout.view.addSubview(UIView()) - assertThat(layout.view.subviews).hasSize(1) - - layout.reset() - assertThat(layout.view.subviews).hasSize(0) - } - - @Test fun resetClearsTrackedWidgets() { - val layout = TreehouseUIView(throwingWidgetSystem) - - // Needed to access internal state which cannot be reasonably observed through the public API. - val children = layout.children as UIViewChildren - - children.insert(0, viewWidget(UIView())) - assertThat(children.widgets).hasSize(1) - - layout.reset() - assertThat(children.widgets).hasSize(0) - } - @Test fun uiConfigurationReflectsInitialUiMode() { val parent = UIWindow() parent.overrideUserInterfaceStyle = UIUserInterfaceStyleDark val layout = TreehouseUIView(throwingWidgetSystem) - parent.addSubview(layout.view) + parent.addSubview(layout.root.value) assertThat(layout.uiConfiguration.value.darkMode).isTrue() } @@ -103,7 +79,7 @@ class TreehouseUIViewTest { val parent = UIWindow() val layout = TreehouseUIView(throwingWidgetSystem) - parent.addSubview(layout.view) + parent.addSubview(layout.root.value) layout.uiConfiguration.test { assertThat(awaitItem().darkMode).isFalse() @@ -127,7 +103,7 @@ class TreehouseUIViewTest { } val layout = TreehouseUIView(throwingWidgetSystem) - parent.addSubview(layout.view) + parent.addSubview(layout.root.value) assertThat(layout.uiConfiguration.value.safeAreaInsets).isEqualTo(expectedInsets) } @@ -137,7 +113,7 @@ class TreehouseUIViewTest { val parent = UIWindow() val layout = TreehouseUIView(throwingWidgetSystem) - parent.addSubview(layout.view) + parent.addSubview(layout.root.value) val expectedLayoutDirection = when (val direction = parent.effectiveUserInterfaceLayoutDirection) { UIUserInterfaceLayoutDirectionLeftToRight -> LayoutDirection.Ltr diff --git a/redwood-widget-shared-test/src/commonMain/kotlin/app/cash/redwood/widget/AbstractRedwoodViewTest.kt b/redwood-widget-shared-test/src/commonMain/kotlin/app/cash/redwood/widget/AbstractRedwoodViewTest.kt index 2aee8fe1ee..fda3b79476 100644 --- a/redwood-widget-shared-test/src/commonMain/kotlin/app/cash/redwood/widget/AbstractRedwoodViewTest.kt +++ b/redwood-widget-shared-test/src/commonMain/kotlin/app/cash/redwood/widget/AbstractRedwoodViewTest.kt @@ -35,7 +35,7 @@ abstract class AbstractRedwoodViewTest> { @Test fun testSingleChildElement() { val redwoodView = redwoodView() - redwoodView.children.insert(0, widgetFactory.text("Hello ".repeat(50))) + redwoodView.root.children.insert(0, widgetFactory.text("Hello ".repeat(50))) snapshotter(redwoodView).snapshot() } } diff --git a/redwood-widget-uiview-test/src/commonTest/kotlin/app/cash/redwood/widget/uiview/UIViewRedwoodViewTest.kt b/redwood-widget-uiview-test/src/commonTest/kotlin/app/cash/redwood/widget/uiview/UIViewRedwoodViewTest.kt index bca269c7d2..683f9bb417 100644 --- a/redwood-widget-uiview-test/src/commonTest/kotlin/app/cash/redwood/widget/uiview/UIViewRedwoodViewTest.kt +++ b/redwood-widget-uiview-test/src/commonTest/kotlin/app/cash/redwood/widget/uiview/UIViewRedwoodViewTest.kt @@ -20,9 +20,6 @@ import app.cash.redwood.snapshot.testing.UIViewSnapshotter import app.cash.redwood.snapshot.testing.UIViewTestWidgetFactory import app.cash.redwood.widget.AbstractRedwoodViewTest import app.cash.redwood.widget.RedwoodUIView -import kotlinx.cinterop.cValue -import platform.CoreGraphics.CGRectZero -import platform.UIKit.UIStackView import platform.UIKit.UIView class UIViewRedwoodViewTest( @@ -30,8 +27,8 @@ class UIViewRedwoodViewTest( ) : AbstractRedwoodViewTest() { override val widgetFactory = UIViewTestWidgetFactory - override fun redwoodView() = RedwoodUIView(UIStackView(cValue { CGRectZero })) + override fun redwoodView() = RedwoodUIView() override fun snapshotter(redwoodView: RedwoodUIView) = - UIViewSnapshotter.framed(callback, redwoodView.view) + UIViewSnapshotter.framed(callback, redwoodView.root.value) } diff --git a/redwood-widget/api/android/redwood-widget.api b/redwood-widget/api/android/redwood-widget.api index 6ea1de52c4..a579ccca78 100644 --- a/redwood-widget/api/android/redwood-widget.api +++ b/redwood-widget/api/android/redwood-widget.api @@ -49,22 +49,29 @@ public final class app/cash/redwood/widget/MutableListChildren : app/cash/redwoo } public class app/cash/redwood/widget/RedwoodLayout : android/widget/FrameLayout, app/cash/redwood/widget/RedwoodView { - public fun (Landroid/content/Context;Landroidx/activity/OnBackPressedDispatcher;)V - public fun getChildren ()Lapp/cash/redwood/widget/Widget$Children; + public fun (Landroid/content/Context;Landroidx/activity/OnBackPressedDispatcher;Lapp/cash/redwood/widget/RedwoodView$Root;)V + public synthetic fun (Landroid/content/Context;Landroidx/activity/OnBackPressedDispatcher;Lapp/cash/redwood/widget/RedwoodView$Root;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun getOnBackPressedDispatcher ()Lapp/cash/redwood/ui/OnBackPressedDispatcher; + public final fun getRoot ()Lapp/cash/redwood/widget/RedwoodView$Root; public fun getSavedStateRegistry ()Lapp/cash/redwood/widget/SavedStateRegistry; public fun getUiConfiguration ()Lkotlinx/coroutines/flow/StateFlow; protected fun onConfigurationChanged (Landroid/content/res/Configuration;)V protected fun onLayout (ZIIII)V - public fun reset ()V + protected fun onMeasure (II)V } public abstract interface class app/cash/redwood/widget/RedwoodView { - public abstract fun getChildren ()Lapp/cash/redwood/widget/Widget$Children; public abstract fun getOnBackPressedDispatcher ()Lapp/cash/redwood/ui/OnBackPressedDispatcher; + public abstract fun getRoot ()Lapp/cash/redwood/widget/RedwoodView$Root; public abstract fun getSavedStateRegistry ()Lapp/cash/redwood/widget/SavedStateRegistry; public abstract fun getUiConfiguration ()Lkotlinx/coroutines/flow/StateFlow; - public abstract fun reset ()V +} + +public abstract interface class app/cash/redwood/widget/RedwoodView$Root : app/cash/redwood/widget/Widget { + public abstract fun contentState (IZLjava/lang/Throwable;)V + public static synthetic fun contentState$default (Lapp/cash/redwood/widget/RedwoodView$Root;IZLjava/lang/Throwable;ILjava/lang/Object;)V + public abstract fun getChildren ()Lapp/cash/redwood/widget/Widget$Children; + public abstract fun restart (Lkotlin/jvm/functions/Function0;)V } public abstract interface class app/cash/redwood/widget/SavedStateRegistry { @@ -85,6 +92,19 @@ public final class app/cash/redwood/widget/ViewGroupChildren : app/cash/redwood/ public fun remove (II)V } +public class app/cash/redwood/widget/ViewRoot : android/view/ViewGroup, app/cash/redwood/widget/RedwoodView$Root { + public fun (Landroid/content/Context;)V + public fun contentState (IZLjava/lang/Throwable;)V + public final fun getChildren ()Lapp/cash/redwood/widget/Widget$Children; + public final fun getModifier ()Lapp/cash/redwood/Modifier; + public final fun getValue ()Landroid/view/View; + public synthetic fun getValue ()Ljava/lang/Object; + protected fun onLayout (ZIIII)V + protected fun onMeasure (II)V + public fun restart (Lkotlin/jvm/functions/Function0;)V + public final fun setModifier (Lapp/cash/redwood/Modifier;)V +} + public abstract interface class app/cash/redwood/widget/Widget { public abstract fun getModifier ()Lapp/cash/redwood/Modifier; public abstract fun getValue ()Ljava/lang/Object; diff --git a/redwood-widget/api/jvm/redwood-widget.api b/redwood-widget/api/jvm/redwood-widget.api index d80f32cd6f..e86d3d77e8 100644 --- a/redwood-widget/api/jvm/redwood-widget.api +++ b/redwood-widget/api/jvm/redwood-widget.api @@ -49,11 +49,17 @@ public final class app/cash/redwood/widget/MutableListChildren : app/cash/redwoo } public abstract interface class app/cash/redwood/widget/RedwoodView { - public abstract fun getChildren ()Lapp/cash/redwood/widget/Widget$Children; public abstract fun getOnBackPressedDispatcher ()Lapp/cash/redwood/ui/OnBackPressedDispatcher; + public abstract fun getRoot ()Lapp/cash/redwood/widget/RedwoodView$Root; public abstract fun getSavedStateRegistry ()Lapp/cash/redwood/widget/SavedStateRegistry; public abstract fun getUiConfiguration ()Lkotlinx/coroutines/flow/StateFlow; - public abstract fun reset ()V +} + +public abstract interface class app/cash/redwood/widget/RedwoodView$Root : app/cash/redwood/widget/Widget { + public abstract fun contentState (IZLjava/lang/Throwable;)V + public static synthetic fun contentState$default (Lapp/cash/redwood/widget/RedwoodView$Root;IZLjava/lang/Throwable;ILjava/lang/Object;)V + public abstract fun getChildren ()Lapp/cash/redwood/widget/Widget$Children; + public abstract fun restart (Lkotlin/jvm/functions/Function0;)V } public abstract interface class app/cash/redwood/widget/SavedStateRegistry { diff --git a/redwood-widget/api/redwood-widget.klib.api b/redwood-widget/api/redwood-widget.klib.api index da76ff1e28..622897e333 100644 --- a/redwood-widget/api/redwood-widget.klib.api +++ b/redwood-widget/api/redwood-widget.klib.api @@ -8,16 +8,22 @@ // Library unique name: abstract interface <#A: kotlin/Any> app.cash.redwood.widget/RedwoodView { // app.cash.redwood.widget/RedwoodView|null[0] - abstract val children // app.cash.redwood.widget/RedwoodView.children|{}children[0] - abstract fun (): app.cash.redwood.widget/Widget.Children<#A> // app.cash.redwood.widget/RedwoodView.children.|(){}[0] abstract val onBackPressedDispatcher // app.cash.redwood.widget/RedwoodView.onBackPressedDispatcher|{}onBackPressedDispatcher[0] abstract fun (): app.cash.redwood.ui/OnBackPressedDispatcher // app.cash.redwood.widget/RedwoodView.onBackPressedDispatcher.|(){}[0] + abstract val root // app.cash.redwood.widget/RedwoodView.root|{}root[0] + abstract fun (): app.cash.redwood.widget/RedwoodView.Root<#A> // app.cash.redwood.widget/RedwoodView.root.|(){}[0] abstract val savedStateRegistry // app.cash.redwood.widget/RedwoodView.savedStateRegistry|{}savedStateRegistry[0] abstract fun (): app.cash.redwood.widget/SavedStateRegistry? // app.cash.redwood.widget/RedwoodView.savedStateRegistry.|(){}[0] abstract val uiConfiguration // app.cash.redwood.widget/RedwoodView.uiConfiguration|{}uiConfiguration[0] abstract fun (): kotlinx.coroutines.flow/StateFlow // app.cash.redwood.widget/RedwoodView.uiConfiguration.|(){}[0] - abstract fun reset() // app.cash.redwood.widget/RedwoodView.reset|reset(){}[0] + abstract interface <#A1: kotlin/Any> Root : app.cash.redwood.widget/Widget<#A1> { // app.cash.redwood.widget/RedwoodView.Root|null[0] + abstract val children // app.cash.redwood.widget/RedwoodView.Root.children|{}children[0] + abstract fun (): app.cash.redwood.widget/Widget.Children<#A1> // app.cash.redwood.widget/RedwoodView.Root.children.|(){}[0] + + abstract fun contentState(kotlin/Int, kotlin/Boolean, kotlin/Throwable? = ...) // app.cash.redwood.widget/RedwoodView.Root.contentState|contentState(kotlin.Int;kotlin.Boolean;kotlin.Throwable?){}[0] + abstract fun restart(kotlin/Function0?) // app.cash.redwood.widget/RedwoodView.Root.restart|restart(kotlin.Function0?){}[0] + } } abstract interface <#A: kotlin/Any> app.cash.redwood.widget/Widget { // app.cash.redwood.widget/Widget|null[0] @@ -123,12 +129,11 @@ final class app.cash.redwood.widget/UIViewChildren : app.cash.redwood.widget/Wid // Targets: [ios] open class app.cash.redwood.widget/RedwoodUIView : app.cash.redwood.widget/RedwoodView { // app.cash.redwood.widget/RedwoodUIView|null[0] - constructor (platform.UIKit/UIView) // app.cash.redwood.widget/RedwoodUIView.|(platform.UIKit.UIView){}[0] + constructor () // app.cash.redwood.widget/RedwoodUIView.|(){}[0] + constructor (app.cash.redwood.widget/UIViewRoot) // app.cash.redwood.widget/RedwoodUIView.|(app.cash.redwood.widget.UIViewRoot){}[0] - final val view // app.cash.redwood.widget/RedwoodUIView.view|{}view[0] - final fun (): platform.UIKit/UIView // app.cash.redwood.widget/RedwoodUIView.view.|(){}[0] - open val children // app.cash.redwood.widget/RedwoodUIView.children|{}children[0] - open fun (): app.cash.redwood.widget/Widget.Children // app.cash.redwood.widget/RedwoodUIView.children.|(){}[0] + final val root // app.cash.redwood.widget/RedwoodUIView.root|{}root[0] + final fun (): app.cash.redwood.widget/UIViewRoot // app.cash.redwood.widget/RedwoodUIView.root.|(){}[0] open val onBackPressedDispatcher // app.cash.redwood.widget/RedwoodUIView.onBackPressedDispatcher|{}onBackPressedDispatcher[0] open fun (): app.cash.redwood.ui/OnBackPressedDispatcher // app.cash.redwood.widget/RedwoodUIView.onBackPressedDispatcher.|(){}[0] open val savedStateRegistry // app.cash.redwood.widget/RedwoodUIView.savedStateRegistry|{}savedStateRegistry[0] @@ -137,7 +142,24 @@ open class app.cash.redwood.widget/RedwoodUIView : app.cash.redwood.widget/Redwo open fun (): kotlinx.coroutines.flow/StateFlow // app.cash.redwood.widget/RedwoodUIView.uiConfiguration.|(){}[0] final fun updateUiConfiguration() // app.cash.redwood.widget/RedwoodUIView.updateUiConfiguration|updateUiConfiguration(){}[0] - open fun reset() // app.cash.redwood.widget/RedwoodUIView.reset|reset(){}[0] + open fun superviewChanged() // app.cash.redwood.widget/RedwoodUIView.superviewChanged|superviewChanged(){}[0] +} + +// Targets: [ios] +open class app.cash.redwood.widget/UIViewRoot : app.cash.redwood.widget/RedwoodView.Root { // app.cash.redwood.widget/UIViewRoot|null[0] + constructor () // app.cash.redwood.widget/UIViewRoot.|(){}[0] + + open val children // app.cash.redwood.widget/UIViewRoot.children|{}children[0] + open fun (): app.cash.redwood.widget/Widget.Children // app.cash.redwood.widget/UIViewRoot.children.|(){}[0] + open val value // app.cash.redwood.widget/UIViewRoot.value|{}value[0] + open fun (): platform.UIKit/UIView // app.cash.redwood.widget/UIViewRoot.value.|(){}[0] + + open var modifier // app.cash.redwood.widget/UIViewRoot.modifier|{}modifier[0] + open fun (): app.cash.redwood/Modifier // app.cash.redwood.widget/UIViewRoot.modifier.|(){}[0] + open fun (app.cash.redwood/Modifier) // app.cash.redwood.widget/UIViewRoot.modifier.|(app.cash.redwood.Modifier){}[0] + + open fun contentState(kotlin/Int, kotlin/Boolean, kotlin/Throwable?) // app.cash.redwood.widget/UIViewRoot.contentState|contentState(kotlin.Int;kotlin.Boolean;kotlin.Throwable?){}[0] + open fun restart(kotlin/Function0?) // app.cash.redwood.widget/UIViewRoot.restart|restart(kotlin.Function0?){}[0] } // Targets: [js] @@ -154,5 +176,22 @@ final class app.cash.redwood.widget/HTMLElementChildren : app.cash.redwood.widge final fun remove(kotlin/Int, kotlin/Int) // app.cash.redwood.widget/HTMLElementChildren.remove|remove(kotlin.Int;kotlin.Int){}[0] } +// Targets: [js] +final class app.cash.redwood.widget/HTMLRoot : app.cash.redwood.widget/RedwoodView.Root { // app.cash.redwood.widget/HTMLRoot|null[0] + constructor (org.w3c.dom/HTMLElement) // app.cash.redwood.widget/HTMLRoot.|(org.w3c.dom.HTMLElement){}[0] + + final val children // app.cash.redwood.widget/HTMLRoot.children|{}children[0] + final fun (): app.cash.redwood.widget/Widget.Children // app.cash.redwood.widget/HTMLRoot.children.|(){}[0] + final val value // app.cash.redwood.widget/HTMLRoot.value|{}value[0] + final fun (): org.w3c.dom/HTMLElement // app.cash.redwood.widget/HTMLRoot.value.|(){}[0] + + final var modifier // app.cash.redwood.widget/HTMLRoot.modifier|{}modifier[0] + final fun (): app.cash.redwood/Modifier // app.cash.redwood.widget/HTMLRoot.modifier.|(){}[0] + final fun (app.cash.redwood/Modifier) // app.cash.redwood.widget/HTMLRoot.modifier.|(app.cash.redwood.Modifier){}[0] + + final fun contentState(kotlin/Int, kotlin/Boolean, kotlin/Throwable?) // app.cash.redwood.widget/HTMLRoot.contentState|contentState(kotlin.Int;kotlin.Boolean;kotlin.Throwable?){}[0] + final fun restart(kotlin/Function0?) // app.cash.redwood.widget/HTMLRoot.restart|restart(kotlin.Function0?){}[0] +} + // Targets: [js] final fun (org.w3c.dom/HTMLElement).app.cash.redwood.widget/asRedwoodView(): app.cash.redwood.widget/RedwoodView // app.cash.redwood.widget/asRedwoodView|asRedwoodView@org.w3c.dom.HTMLElement(){}[0] diff --git a/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/RedwoodLayout.kt b/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/RedwoodLayout.kt index 46c30191e2..e830e17b64 100644 --- a/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/RedwoodLayout.kt +++ b/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/RedwoodLayout.kt @@ -38,16 +38,15 @@ import kotlinx.coroutines.flow.StateFlow public open class RedwoodLayout( context: Context, androidOnBackPressedDispatcher: AndroidOnBackPressedDispatcher, + public final override val root: RedwoodView.Root = ViewRoot(context), ) : FrameLayout(context), RedwoodView { init { // The view needs to have an ID to participate in instance state saving. id = R.id.redwood_layout + super.addView(root.value) } - private val _children = ViewGroupChildren(this) - override val children: Widget.Children get() = _children - private val mutableUiConfiguration = MutableStateFlow(computeUiConfiguration()) override val onBackPressedDispatcher: RedwoodOnBackPressedDispatcher = @@ -77,23 +76,21 @@ public open class RedwoodLayout( override val uiConfiguration: StateFlow get() = mutableUiConfiguration - override fun reset() { - _children.remove(0, _children.widgets.size) - - // Ensure any out-of-band views are also removed. - removeAllViews() - } - init { setOnWindowInsetsChangeListener { insets -> mutableUiConfiguration.value = computeUiConfiguration(insets = insets.safeDrawing) } } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + root.value.measure(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension(root.value.measuredWidth, root.value.measuredHeight) + } + @SuppressLint("DrawAllocation") // It's only on layout. override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { mutableUiConfiguration.value = computeUiConfiguration() - super.onLayout(changed, left, top, right, bottom) + root.value.layout(0, 0, right - left, bottom - top) } override fun onConfigurationChanged(newConfig: Configuration) { diff --git a/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/ViewRoot.kt b/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/ViewRoot.kt new file mode 100644 index 0000000000..3d879628ac --- /dev/null +++ b/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/ViewRoot.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.widget + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children as viewGroupChildren +import app.cash.redwood.Modifier + +/** + * A default base implementation of [RedwoodView.Root] suitable for subclassing. + * + * This view contributes nothing to the view hierarchy. It forwards all measurement and layout calls + * from its own parent view to its child views. + */ +public open class ViewRoot( + context: Context, +) : ViewGroup(context), + RedwoodView.Root { + final override val children: Widget.Children = ViewGroupChildren(this) + final override val value: View + get() = this + final override var modifier: Modifier = Modifier + + override fun contentState( + loadCount: Int, + attached: Boolean, + uncaughtException: Throwable?, + ) { + } + + override fun restart(restart: (() -> Unit)?) { + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + var maxWidth = 0 + var maxHeight = 0 + for (child in viewGroupChildren) { + child.measure(widthMeasureSpec, heightMeasureSpec) + maxWidth = maxOf(maxWidth, child.measuredWidth) + maxHeight = maxOf(maxHeight, child.measuredHeight) + } + setMeasuredDimension(maxWidth, maxHeight) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + for (child in viewGroupChildren) { + child.layout(0, 0, right - left, bottom - top) + } + } +} diff --git a/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/RedwoodView.kt b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/RedwoodView.kt index 8e098a2aed..dff67e4f8e 100644 --- a/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/RedwoodView.kt +++ b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/RedwoodView.kt @@ -22,15 +22,53 @@ import kotlinx.coroutines.flow.StateFlow @ObjCName("RedwoodView", exact = true) public interface RedwoodView { - public val children: Widget.Children + public val root: Root public val onBackPressedDispatcher: OnBackPressedDispatcher public val uiConfiguration: StateFlow public val savedStateRegistry: SavedStateRegistry? - /** - * This should at minimum clear all [children]. - * - * Invoke when switching the backing composition to prepare the view for an initial load. - */ - public fun reset() + @ObjCName("Root", exact = true) + public interface Root : Widget { + /** + * The current dynamic content. If the application logic is stopped or crashed this will retain + * a snapshot of the most-recent content, but the content will ignore user actions. + */ + public val children: Widget.Children + + /** + * Called for lifecycle changes to the content choreographing this view. + * + * Content may be bound to this view before it has a view tree. When that happens this will + * be called with `loadCount = 0` and `attached = false`. Content won't be ready if it is busy + * downloading, launching, or computing an initial view tree. + * + * When the content's initial view tree is ready, this is called with `loadCount = 1` and + * `attached = true`. This signals that the content is both running and interactive. + * + * If the content stops, this is called again with `attached = false`. This happens when the + * content is detached, either gracefully or due to a crash. Call the lambda provided to + * [restart] to relaunch the content. + * + * Each time the content is replaced dynamically (a ‘hot reload’), this is called with an + * incremented [loadCount]. + * + * @param loadCount how many different versions of content have been loaded for this view. This + * is 1 for the first load, and increments for subsequent reloads. Use this to trigger an + * 'reloaded' message or animation when business logic is updated. This may skip values if + * content stops before it emits its initial view tree. + * @param attached true if the content is interactive. This is false if it has stopped or + * crashed. + * @param uncaughtException the exception that caused the content to detach. + */ + public fun contentState( + loadCount: Int, + attached: Boolean, + uncaughtException: Throwable? = null, + ) + + /** + * Call the provided lambda to restart the business logic that powers this UI. + */ + public fun restart(restart: (() -> Unit)?) + } } diff --git a/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt b/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt index 8ec420c9b5..8028543f8b 100644 --- a/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt +++ b/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/RedwoodUIView.kt @@ -38,17 +38,16 @@ import platform.UIKit.UIUserInterfaceStyle import platform.UIKit.UIView public open class RedwoodUIView( - public val view: UIView, + final override val root: UIViewRoot, ) : RedwoodView { - private val _children = UIViewChildren(view) - override val children: Widget.Children get() = _children + public constructor() : this(UIViewRoot()) private val mutableUiConfiguration = MutableStateFlow( computeUiConfiguration( - traitCollection = view.traitCollection, - layoutDirection = view.effectiveUserInterfaceLayoutDirection, - bounds = view.bounds, + traitCollection = root.value.traitCollection, + layoutDirection = root.value.effectiveUserInterfaceLayoutDirection, + bounds = root.value.bounds, ), ) @@ -66,21 +65,20 @@ public open class RedwoodUIView( override val savedStateRegistry: SavedStateRegistry? get() = null - override fun reset() { - _children.remove(0, _children.widgets.size) - - // Ensure any out-of-band views are also removed. - @Suppress("UNCHECKED_CAST") // Correct generic lost by cinterop. - (view.subviews as List).forEach(UIView::removeFromSuperview) + init { + root.valueRootView.redwoodUIView = this } - protected fun updateUiConfiguration() { + public fun updateUiConfiguration() { mutableUiConfiguration.value = computeUiConfiguration( - traitCollection = view.traitCollection, - layoutDirection = view.effectiveUserInterfaceLayoutDirection, - bounds = view.bounds, + traitCollection = root.value.traitCollection, + layoutDirection = root.value.effectiveUserInterfaceLayoutDirection, + bounds = root.value.bounds, ) } + + public open fun superviewChanged() { + } } internal fun computeUiConfiguration( diff --git a/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/UIViewRoot.kt b/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/UIViewRoot.kt new file mode 100644 index 0000000000..289e4f2750 --- /dev/null +++ b/redwood-widget/src/iosMain/kotlin/app/cash/redwood/widget/UIViewRoot.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.widget + +import app.cash.redwood.Modifier +import kotlinx.cinterop.cValue +import platform.CoreGraphics.CGRectZero +import platform.UIKit.UILayoutConstraintAxisVertical +import platform.UIKit.UIStackView +import platform.UIKit.UIStackViewAlignmentFill +import platform.UIKit.UIStackViewDistributionFillEqually +import platform.UIKit.UITraitCollection +import platform.UIKit.UIView + +/** + * A default base implementation of [RedwoodView.Root] suitable for subclassing. + * + * The [value] view contributes nothing to the view hierarchy. It forwards all measurement and + * layout calls from its own parent view to its child views. + */ +@ObjCName("UIViewRoot", exact = true) +public open class UIViewRoot : RedwoodView.Root { + internal val valueRootView: RootUIStackView = RootUIStackView() + + override val value: UIView + get() = valueRootView + + private val _children = UIViewChildren(valueRootView) + override val children: Widget.Children + get() = _children + + override var modifier: Modifier = Modifier + + override fun contentState( + loadCount: Int, + attached: Boolean, + uncaughtException: Throwable?, + ) { + } + + override fun restart(restart: (() -> Unit)?) { + } + + /** + * In practice we expect this to contain either zero child subviews (especially when + * newly-initialized) or one child subview, which will usually be a layout container. + * + * This could just as easily be a horizontal stack. A box would be even better, but there's no + * such built-in component and implementing it manually is difficult if we want to react to + * content resizes. + */ + internal class RootUIStackView : UIStackView(cValue { CGRectZero }) { + var redwoodUIView: RedwoodUIView? = null + + init { + this.axis = UILayoutConstraintAxisVertical + this.alignment = UIStackViewAlignmentFill // Fill horizontal. + this.distribution = UIStackViewDistributionFillEqually // Fill vertical. + } + + override fun layoutSubviews() { + super.layoutSubviews() + + // Bounds likely changed. Report new size. + redwoodUIView?.updateUiConfiguration() + } + + override fun didMoveToSuperview() { + super.didMoveToSuperview() + redwoodUIView?.superviewChanged() + } + + override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + redwoodUIView?.updateUiConfiguration() + } + } +} diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeEventPublisher.kt b/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/HTMLRoot.kt similarity index 52% rename from redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeEventPublisher.kt rename to redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/HTMLRoot.kt index 42ccbb1fa3..c588581047 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeEventPublisher.kt +++ b/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/HTMLRoot.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 Square, Inc. + * Copyright (C) 2024 Square, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,20 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package app.cash.redwood.treehouse +package app.cash.redwood.widget -internal interface CodeEventPublisher { - fun onInitialCodeLoading( - view: TreehouseView<*>, - ) +import app.cash.redwood.Modifier +import org.w3c.dom.HTMLElement - fun onCodeLoaded( - view: TreehouseView<*>, - initial: Boolean, - ) +public class HTMLRoot( + override val value: HTMLElement, +) : RedwoodView.Root { + override val children: Widget.Children = HTMLElementChildren(value) + override var modifier: Modifier = Modifier - fun onCodeDetached( - view: TreehouseView<*>, - exception: Throwable?, - ) + override fun contentState(loadCount: Int, attached: Boolean, uncaughtException: Throwable?) { + } + + override fun restart(restart: (() -> Unit)?) { + } } diff --git a/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/RedwoodHTMLElementView.kt b/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/RedwoodHTMLElementView.kt index d4a3cfbf20..80be9f9002 100644 --- a/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/RedwoodHTMLElementView.kt +++ b/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/RedwoodHTMLElementView.kt @@ -22,11 +22,9 @@ import app.cash.redwood.ui.OnBackPressedDispatcher import app.cash.redwood.ui.Size import app.cash.redwood.ui.UiConfiguration import app.cash.redwood.ui.dp -import app.cash.redwood.widget.Widget.Children import kotlinx.browser.window import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.dom.clear import org.w3c.dom.HTMLElement import org.w3c.dom.MediaQueryList import org.w3c.dom.events.Event @@ -39,10 +37,9 @@ public fun HTMLElement.asRedwoodView(): RedwoodView { } private class RedwoodHTMLElementView( - private val element: HTMLElement, + element: HTMLElement, ) : RedwoodView { - private val _children = HTMLElementChildren(element) - override val children: Children get() = _children + override val root = HTMLRoot(element) private var pixelRatioQueryRemover: (() -> Unit)? = null @@ -121,13 +118,6 @@ private class RedwoodHTMLElementView( } } - override fun reset() { - _children.remove(0, _children.widgets.size) - - // Ensure any out-of-band nodes are also removed. - element.clear() - } - private fun updateUiConfiguration(updater: (UiConfiguration) -> UiConfiguration) { // We skip MutableStateFlow.update because it uses verbose CAS loop and JS only has one thread. _uiConfiguration.value = updater(_uiConfiguration.value) diff --git a/samples/counter/ios-shared/src/commonMain/kotlin/com/example/redwood/counter/ios/CounterViewControllerDelegate.kt b/samples/counter/ios-shared/src/commonMain/kotlin/com/example/redwood/counter/ios/CounterViewControllerDelegate.kt index 333851a18b..625c892bab 100644 --- a/samples/counter/ios-shared/src/commonMain/kotlin/com/example/redwood/counter/ios/CounterViewControllerDelegate.kt +++ b/samples/counter/ios-shared/src/commonMain/kotlin/com/example/redwood/counter/ios/CounterViewControllerDelegate.kt @@ -19,16 +19,16 @@ import app.cash.redwood.compose.DisplayLinkClock import app.cash.redwood.compose.RedwoodComposition import app.cash.redwood.layout.uiview.UIViewRedwoodLayoutWidgetFactory import app.cash.redwood.widget.RedwoodUIView +import app.cash.redwood.widget.UIViewRoot import com.example.redwood.counter.presenter.Counter import com.example.redwood.counter.widget.SchemaWidgetSystem import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.plus -import platform.UIKit.UIStackView @Suppress("unused") // Called from Swift. class CounterViewControllerDelegate( - root: UIStackView, + root: UIViewRoot, ) { private val scope = MainScope() + DisplayLinkClock diff --git a/samples/counter/ios-uikit/CounterApp/CounterViewController.swift b/samples/counter/ios-uikit/CounterApp/CounterViewController.swift index 69ad877a64..63aed6ee43 100644 --- a/samples/counter/ios-uikit/CounterApp/CounterViewController.swift +++ b/samples/counter/ios-uikit/CounterApp/CounterViewController.swift @@ -6,19 +6,18 @@ class CounterViewController : UIViewController { private var delegate: CounterViewControllerDelegate! override func viewDidLoad() { - let container = UIStackView() - container.axis = .horizontal - container.alignment = .fill - container.distribution = .fillEqually - container.translatesAutoresizingMaskIntoConstraints = false + let root = UIViewRoot() - view.addSubview(container) - container.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - container.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - container.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true - container.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true + let rootView = root.value + rootView.translatesAutoresizingMaskIntoConstraints = false - self.delegate = CounterViewControllerDelegate(root: container) + view.addSubview(root.value) + rootView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + rootView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + rootView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true + rootView.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true + + self.delegate = CounterViewControllerDelegate(root: root) } deinit { diff --git a/samples/emoji-search/android-composeui/src/androidTest/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchUiTest.kt b/samples/emoji-search/android-composeui/src/androidTest/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchUiTest.kt index 54aa700c03..acdf10798c 100644 --- a/samples/emoji-search/android-composeui/src/androidTest/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchUiTest.kt +++ b/samples/emoji-search/android-composeui/src/androidTest/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchUiTest.kt @@ -17,5 +17,11 @@ package com.example.redwood.emojisearch.android.composeui import com.example.redwood.emojisearch.android.composeui.BuildConfig.APPLICATION_ID import com.example.redwood.emojisearch.android.tests.AbstractEmojiSearchUiTest +import org.junit.Test -class EmojiSearchUiTest : AbstractEmojiSearchUiTest(APPLICATION_ID) +class EmojiSearchUiTest : AbstractEmojiSearchUiTest(APPLICATION_ID) { + @Test + fun searchTrees2() { + searchTrees() + } +} diff --git a/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt b/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt index f3b1edb4b7..5edf9a4d1b 100644 --- a/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt +++ b/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt @@ -116,6 +116,7 @@ class EmojiSearchActivity : ComponentActivity() { widgetSystem = widgetSystem, contentSource = treehouseContentSource, modifier = Modifier.padding(contentPadding), + root = { scope -> EmojiSearchComposeUiRoot(scope) }, ) } } diff --git a/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchComposeUiRoot.kt b/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchComposeUiRoot.kt new file mode 100644 index 0000000000..d24937475b --- /dev/null +++ b/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchComposeUiRoot.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.redwood.emojisearch.android.composeui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.cash.redwood.treehouse.composeui.ComposeUiRoot +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal class EmojiSearchComposeUiRoot( + private val scope: CoroutineScope, +) : ComposeUiRoot() { + private var loadCount by mutableIntStateOf(0) + private var attached by mutableStateOf(false) + private var uncaughtException by mutableStateOf(null) + private var restart: (() -> Unit)? = null + + override fun contentState( + loadCount: Int, + attached: Boolean, + uncaughtException: Throwable?, + ) { + this.loadCount = loadCount + this.attached = attached + this.uncaughtException = uncaughtException + + if (uncaughtException != null) { + scope.launch { + delay(2_000.milliseconds) + restart?.invoke() + } + } + } + + override fun restart(restart: (() -> Unit)?) { + this.restart = restart + } + + @Composable + override fun Render() { + val uncaughtException = this.uncaughtException + if (uncaughtException != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + BasicText(uncaughtException.stackTraceToString()) + } + return + } + + if (!attached) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + BasicText("loading...") + } + return + } + + super.Render() + } +} diff --git a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt index 3ba2732471..409658dc90 100644 --- a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt +++ b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt @@ -16,17 +16,16 @@ package com.example.redwood.emojisearch.android.views import android.annotation.SuppressLint +import android.content.Context import android.os.Bundle import android.util.Log -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.LinearLayout import androidx.activity.ComponentActivity import androidx.core.view.WindowCompat +import androidx.core.view.iterator import app.cash.redwood.compose.AndroidUiDispatcher.Companion.Main import app.cash.redwood.layout.view.ViewRedwoodLayoutWidgetFactory import app.cash.redwood.lazylayout.view.ViewRedwoodLazyLayoutWidgetFactory import app.cash.redwood.leaks.LeakDetector -import app.cash.redwood.treehouse.CodeListener import app.cash.redwood.treehouse.EventListener import app.cash.redwood.treehouse.TreehouseApp import app.cash.redwood.treehouse.TreehouseAppFactory @@ -34,6 +33,7 @@ import app.cash.redwood.treehouse.TreehouseContentSource import app.cash.redwood.treehouse.TreehouseLayout import app.cash.redwood.treehouse.TreehouseView import app.cash.redwood.treehouse.bindWhenReady +import app.cash.redwood.widget.ViewRoot import app.cash.zipline.Zipline import app.cash.zipline.ZiplineManifest import app.cash.zipline.loader.ManifestVerifier @@ -50,7 +50,9 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import okio.FileSystem @@ -92,26 +94,50 @@ class EmojiSearchActivity : ComponentActivity() { ) } - treehouseLayout = TreehouseLayout(this, widgetSystem, onBackPressedDispatcher).apply { - treehouseContentSource.bindWhenReady(this, treehouseApp, codeListener) + val viewRoot = EmojiSearchViewRoot(context, scope) + + treehouseLayout = TreehouseLayout(this, widgetSystem, onBackPressedDispatcher, viewRoot).apply { + treehouseContentSource.bindWhenReady(this, treehouseApp) } setContentView(treehouseLayout) } - private val codeListener: CodeListener = object : CodeListener() { - override fun onCodeDetached( - app: TreehouseApp<*>, - view: TreehouseView<*>, - exception: Throwable?, + private class EmojiSearchViewRoot( + context: Context, + private val scope: CoroutineScope, + ) : ViewRoot(context) { + private var restart: (() -> Unit)? = null + + override fun contentState( + loadCount: Int, + attached: Boolean, + uncaughtException: Throwable?, ) { - if (exception != null) { - treehouseLayout.reset() - treehouseLayout.addView( - ExceptionView(treehouseLayout, exception), - LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT), - ) + if (uncaughtException != null) { + scope.launch { + delay(2.seconds) + restart?.invoke() + } + } + + val i = iterator() + while (i.hasNext()) { + val child = i.next() + if (child is ExceptionView || child is LoadingView) { + i.remove() + } + } + if (loadCount == 0) { + addView(LoadingView(context)) + } + if (uncaughtException != null) { + addView(ExceptionView(context, uncaughtException)) } } + + override fun restart(restart: (() -> Unit)?) { + this.restart = restart + } } private val appEventListener: EventListener = object : EventListener() { diff --git a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/ExceptionView.kt b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/ExceptionView.kt index bc24fc11f8..8cfa0efe71 100644 --- a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/ExceptionView.kt +++ b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/ExceptionView.kt @@ -16,6 +16,7 @@ package com.example.redwood.emojisearch.android.views import android.annotation.SuppressLint +import android.content.Context import android.graphics.Color import android.text.TextUtils import android.view.Gravity.CENTER @@ -23,7 +24,6 @@ import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.LinearLayout import androidx.appcompat.widget.AppCompatTextView -import app.cash.redwood.treehouse.TreehouseLayout import app.cash.redwood.ui.Density import app.cash.redwood.ui.dp @@ -40,9 +40,9 @@ import app.cash.redwood.ui.dp */ @SuppressLint("ViewConstructor") internal class ExceptionView( - parent: TreehouseLayout, + context: Context, private val exception: Throwable, -) : LinearLayout(parent.context) { +) : LinearLayout(context) { init { orientation = VERTICAL diff --git a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/LoadingView.kt b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/LoadingView.kt new file mode 100644 index 0000000000..9d01e0eda0 --- /dev/null +++ b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/LoadingView.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.redwood.emojisearch.android.views + +import android.annotation.SuppressLint +import android.content.Context +import android.view.Gravity.CENTER +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import android.widget.ProgressBar +import androidx.appcompat.view.ContextThemeWrapper + +@SuppressLint("ViewConstructor") +internal class LoadingView( + context: Context, +) : FrameLayout(context) { + init { + addView( + ProgressBar( + ContextThemeWrapper(context, android.R.style.Widget_ProgressBar_Small), + ), + LayoutParams(WRAP_CONTENT, WRAP_CONTENT, CENTER), + ) + } +} diff --git a/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift b/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift index 92c854cbec..42e0f9c71b 100644 --- a/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift +++ b/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift @@ -38,13 +38,15 @@ class EmojiSearchViewController : UIViewController, EmojiSearchEventListener { let emojiSearchLauncher = EmojiSearchLauncher(nsurlSession: urlSession, hostApi: IosHostApi()) let treehouseApp = emojiSearchLauncher.createTreehouseApp(listener: self) let widgetSystem = EmojiSearchTreehouseWidgetSystem(treehouseApp: treehouseApp) - let treehouseView = TreehouseUIView(widgetSystem: widgetSystem) + let treehouseView = TreehouseUIView( + widgetSystem: widgetSystem, + root: EmojiSearchUIViewRoot() + ) let content = treehouseApp.createContent( - source: EmojiSearchContent(), - codeListener: EmojiSearchCodeListener(treehouseView) + source: EmojiSearchContent() ) ExposedKt.bindWhenReady(content: content, view: treehouseView) - view = treehouseView.view + view = treehouseView.root.value } func codeLoadFailed() { @@ -71,28 +73,33 @@ class EmojiSearchViewController : UIViewController, EmojiSearchEventListener { } } -class EmojiSearchCodeListener : CodeListener { - let treehouseView: TreehouseUIView - - init(_ treehouseView: TreehouseUIView) { - self.treehouseView = treehouseView - } - - override func onCodeDetached(app: TreehouseApp, view: TreehouseView, exception: KotlinThrowable?) { - if (exception != nil) { - treehouseView.reset() +class EmojiSearchUIViewRoot : UIViewRoot { + override func contentState( + loadCount: Int32, + attached: Bool, + uncaughtException: KotlinThrowable? + ) { + for view in value.subviews { + if let exceptionView = view as? ExceptionView { + exceptionView.removeFromSuperview() + } + } - let exceptionView = ExceptionView(exception!) + if uncaughtException != nil { + let exceptionView = ExceptionView(uncaughtException!) exceptionView.translatesAutoresizingMaskIntoConstraints = false - treehouseView.view.addSubview(exceptionView) + value.addSubview(exceptionView) NSLayoutConstraint.activate([ - exceptionView.topAnchor.constraint(equalTo: treehouseView.view.topAnchor), - exceptionView.leftAnchor.constraint(equalTo: treehouseView.view.leftAnchor), - exceptionView.rightAnchor.constraint(equalTo: treehouseView.view.rightAnchor), - exceptionView.bottomAnchor.constraint(equalTo: treehouseView.view.bottomAnchor), + exceptionView.topAnchor.constraint(equalTo: value.topAnchor), + exceptionView.leftAnchor.constraint(equalTo: value.leftAnchor), + exceptionView.rightAnchor.constraint(equalTo: value.rightAnchor), + exceptionView.bottomAnchor.constraint(equalTo: value.bottomAnchor), ]) } } + + override func restart(restart: (() -> Void)? = nil) { + } } class EmojiSearchContent : TreehouseContentSource { diff --git a/test-app/ios-uikit/TestApp/TestAppViewController.swift b/test-app/ios-uikit/TestApp/TestAppViewController.swift index d1d7819a9b..84e2a0bff9 100644 --- a/test-app/ios-uikit/TestApp/TestAppViewController.swift +++ b/test-app/ios-uikit/TestApp/TestAppViewController.swift @@ -36,12 +36,11 @@ class TestAppViewController : UIViewController { let widgetSystem = TestSchemaTreehouseWidgetSystem() let treehouseView = TreehouseUIView(widgetSystem: widgetSystem) let content = treehouseApp.createContent( - source: TestAppContent(), - codeListener: CodeListener() + source: TestAppContent() ) ExposedKt.bindWhenReady(content: content, view: treehouseView) - let tv = treehouseView.view + let tv = treehouseView.root.value tv.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(tv)