diff --git a/kotlin/build.gradle b/kotlin/build.gradle index b45778d7f..4e612dcb9 100644 --- a/kotlin/build.gradle +++ b/kotlin/build.gradle @@ -17,15 +17,16 @@ buildscript { ext.versions = [ 'agp': '3.2.1', 'buildTools': '28.0.3', - 'compileSdk': '27', + 'compileSdk': '28', + 'lifecycle': '1.1.1', 'minSdk': 21, - 'targetSdk': 26, + 'targetSdk': 28, 'sourceCompatibility': JavaVersion.VERSION_1_8, 'targetCompatibility': JavaVersion.VERSION_1_8, 'coordinators': '0.4', 'rxandroid2': '2.1.0', - 'support': '26.0.2', + 'support': '28.0.0', 'timber': '4.7.1', 'assertj': '2.6.0', @@ -50,6 +51,7 @@ buildscript { 'coordinators': "com.squareup.coordinators:coordinators:${versions.coordinators}", 'constraint_layout': "com.android.support.constraint:constraint-layout:1.1.0", 'design': "com.android.support:design:${versions.support}", + 'lifecycle': "android.arch.lifecycle:extensions:${versions.lifecycle}", 'rxandroid2': "io.reactivex.rxjava2:rxandroid:${versions.rxandroid2}", 'timber': "com.jakewharton.timber:timber:${versions.timber}", 'transition': "com.android.support:transition:${versions.support}", diff --git a/kotlin/samples/tictactoe/android/build.gradle b/kotlin/samples/tictactoe/android/build.gradle index e7d7ecfa3..7fddeb456 100644 --- a/kotlin/samples/tictactoe/android/build.gradle +++ b/kotlin/samples/tictactoe/android/build.gradle @@ -38,6 +38,7 @@ dependencies { implementation deps.appcompatv7 implementation deps.constraint_layout implementation deps.design + implementation deps.lifecycle implementation deps.kotlin.coroutines.rx2 implementation deps.kotlin.reflect implementation deps.okio diff --git a/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/MainActivity.kt b/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/MainActivity.kt index bc016a148..7af5dfd56 100644 --- a/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/MainActivity.kt +++ b/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/MainActivity.kt @@ -17,87 +17,49 @@ package com.squareup.sample.mainactivity import android.os.Bundle import android.support.v7.app.AppCompatActivity -import android.view.View import com.squareup.sample.authworkflow.AuthViewBindings -import com.squareup.sample.gameworkflow.RunGameScreen import com.squareup.sample.gameworkflow.TicTacToeViewBindings import com.squareup.sample.panel.PanelContainer -import com.squareup.workflow.ui.AlertContainerScreen -import com.squareup.workflow.ui.HandlesBack import com.squareup.workflow.ui.ModalContainer -import com.squareup.workflow.ui.ViewBinding +import com.squareup.workflow.ui.PickledWorkflow import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.WorkflowActivityRunner import com.squareup.workflow.ui.backstack.BackStackContainer import com.squareup.workflow.ui.backstack.PushPopEffect -import com.squareup.workflow.Snapshot -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.CompositeDisposable -import kotlin.reflect.jvm.jvmName +import com.squareup.workflow.ui.setContentWorkflow -/** - * Prototype Android integration. Demonstrates: - * - * - preserving workflow state via the activity bundle - * - simple two layer container, with body views and dialogs - * - TODO: customizing stock views via wrapping (when we add a logout button to each game view) - */ class MainActivity : AppCompatActivity() { private lateinit var component: MainComponent - - private lateinit var content: View - - private val subs = CompositeDisposable() - - private var latestSnapshot = Snapshot.EMPTY + private lateinit var workflowViewModel: WorkflowActivityRunner<*, *> override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val restored = savedInstanceState?.getParcelable(WORKFLOW_BUNDLE_KEY) component = lastCustomNonConfigurationInstance as? MainComponent ?: MainComponent() - val snapshot = savedInstanceState?.getParcelable(SNAPSHOT_NAME) - ?.snapshot - val updates = component.updates(snapshot) - - val screens = updates - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { latestSnapshot = it.snapshot } - .map { it.rendering } - - val viewRegistry = buildViewRegistry() - val rootViewBinding: ViewBinding = - viewRegistry.getBinding(AlertContainerScreen::class.jvmName) - - content = rootViewBinding.buildView(screens, viewRegistry, this) - .apply { setContentView(this) } + workflowViewModel = setContentWorkflow(viewRegistry, component.mainWorkflow, restored) } override fun onBackPressed() { - if (!HandlesBack.Helper.onBackPressed(content)) super.onBackPressed() + if (!workflowViewModel.onBackPressed(this)) super.onBackPressed() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putParcelable(SNAPSHOT_NAME, ParceledSnapshot(latestSnapshot)) - } - - override fun onDestroy() { - subs.clear() - super.onDestroy() + outState.putParcelable(WORKFLOW_BUNDLE_KEY, workflowViewModel.asParcelable) } override fun onRetainCustomNonConfigurationInstance(): Any = component - private fun buildViewRegistry(): ViewRegistry { - return ViewRegistry( + private companion object { + const val WORKFLOW_BUNDLE_KEY = "workflow" + + val viewRegistry = ViewRegistry( BackStackContainer, ModalContainer.forAlertContainerScreen(), PanelContainer ) + AuthViewBindings + TicTacToeViewBindings + PushPopEffect } - - private companion object { - val SNAPSHOT_NAME = MainActivity::class.jvmName + "-snapshot" - } } diff --git a/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/MainComponent.kt b/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/MainComponent.kt index b32c70045..78a7833b0 100644 --- a/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/MainComponent.kt +++ b/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/MainComponent.kt @@ -21,17 +21,10 @@ import com.squareup.sample.authworkflow.RealAuthWorkflow import com.squareup.sample.gameworkflow.RealGameLog import com.squareup.sample.gameworkflow.RealRunGameWorkflow import com.squareup.sample.gameworkflow.RealTakeTurnsWorkflow -import com.squareup.sample.gameworkflow.RunGameScreen import com.squareup.sample.gameworkflow.RunGameWorkflow import com.squareup.sample.gameworkflow.TakeTurnsWorkflow import com.squareup.sample.mainworkflow.MainWorkflow -import com.squareup.workflow.Snapshot -import com.squareup.workflow.WorkflowHost -import com.squareup.workflow.WorkflowHost.Update -import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers.mainThread -import kotlinx.coroutines.experimental.Dispatchers -import kotlinx.coroutines.experimental.rx2.asObservable import timber.log.Timber /** @@ -39,12 +32,8 @@ import timber.log.Timber */ internal class MainComponent { - private val workflowHostFactory = WorkflowHost.Factory(Dispatchers.Unconfined) - private val authService = AuthService() - private fun mainWorkflow() = MainWorkflow(authWorkflow(), gameWorkflow()) - private fun authWorkflow(): AuthWorkflow = RealAuthWorkflow(authService) private fun gameLog() = RealGameLog(mainThread()) @@ -53,25 +42,7 @@ internal class MainComponent { private fun takeTurnsWorkflow(): TakeTurnsWorkflow = RealTakeTurnsWorkflow() - private fun workflowHost(snapshot: Snapshot?) = workflowHostFactory.run(mainWorkflow(), snapshot) - - private var updates: Observable>? = null - - fun updates(snapshot: Snapshot?): Observable> { - if (updates == null) { - val host = workflowHost(snapshot) - - @Suppress("EXPERIMENTAL_FEATURE_WARNING") - updates = host.updates.asObservable(Dispatchers.Unconfined) - .doOnNext { Timber.d("showing: %s", it.rendering) } - .replay(1) - .autoConnect() - - // autoConnect() is leaky (it's never disposed), but we want it to run - // forever so ¯\_(ツ)_/¯. - } - return updates!! - } + val mainWorkflow = MainWorkflow(authWorkflow(), gameWorkflow()) companion object { init { diff --git a/kotlin/workflow-ui-android/build.gradle b/kotlin/workflow-ui-android/build.gradle index 960111a69..a6406d524 100644 --- a/kotlin/workflow-ui-android/build.gradle +++ b/kotlin/workflow-ui-android/build.gradle @@ -23,13 +23,19 @@ targetCompatibility = JavaVersion.VERSION_1_7 android rootProject.ext.defaultAndroidConfig dependencies { + api project(':workflow-core') api project(':workflow-ui-core') api deps.coordinators api deps.kotlin.stdLib.jdk6 + api deps.lifecycle api deps.rxjava2.rxjava2 api deps.transition + implementation project(':workflow-runtime') implementation deps.appcompatv7 + implementation deps.kotlin.coroutines.core + implementation deps.kotlin.coroutines.rx2 implementation deps.kotlin.reflect + implementation deps.rxandroid2 } diff --git a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/HandlesBack.kt b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/HandlesBack.kt index b12f9bfa2..582ec27a3 100644 --- a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/HandlesBack.kt +++ b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/HandlesBack.kt @@ -21,14 +21,17 @@ import com.squareup.workflow.ui.HandlesBack.Helper.setConditionalBackHandler /** * Implemented by objects that want the option to intercept back button taps. * Can be implemented by [View] subclasses, or can be attached to a stock view via - * [Helper]. + * [Helper.setBackHandler]. * - * When implemented by a container view, the [onBackPressed] methods or tags of their - * subviews should be invoked first. + * When implemented by a container view, the [onBackPressed] methods or tags of its + * subviews should be invoked first, via [Helper.onBackPressed] * - * The typical flow of back button handling starts in the [android.app.Activity.onBackPressed] - * calling [onBackPressed] on its content view. Each view in turn delegates to its - * child views to give them first say. + * To kick things off, override [android.app.Activity.onBackPressed] to call + * [WorkflowActivityRunner.onBackPressed]: + * + * override fun onBackPressed() { + * if (!workflowViewModel.onBackPressed(this)) super.onBackPressed() + * } */ interface HandlesBack { /** diff --git a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/ModalContainer.kt b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/ModalContainer.kt index 570056018..3f0855843 100644 --- a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/ModalContainer.kt +++ b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/ModalContainer.kt @@ -155,7 +155,7 @@ abstract class ModalContainer override fun onSaveInstanceState(): Parcelable { return SavedState( - super.onSaveInstanceState(), + super.onSaveInstanceState()!!, SparseArray().also { array -> base?.saveHierarchyState(array) }, dialogs.map { it.save() } ) @@ -196,8 +196,8 @@ abstract class ModalContainer companion object CREATOR : Creator { override fun createFromParcel(parcel: Parcel): TypeAndBundle { - val type = parcel.readString() - val bundle = parcel.readBundle(TypeAndBundle::class.java.classLoader) + val type = parcel.readString()!! + val bundle = parcel.readBundle(TypeAndBundle::class.java.classLoader)!! return TypeAndBundle(type, bundle) } @@ -210,13 +210,13 @@ abstract class ModalContainer val dialog: Dialog ) { fun save(): TypeAndBundle { - val saved = dialog.window.saveHierarchyState() + val saved = dialog.window!!.saveHierarchyState() return TypeAndBundle(screen::class.jvmName, saved) } fun restore(typeAndBundle: TypeAndBundle) { if (screen::class.jvmName == typeAndBundle.screenType) { - dialog.window.restoreHierarchyState(typeAndBundle.bundle) + dialog.window!!.restoreHierarchyState(typeAndBundle.bundle) } } } @@ -244,7 +244,7 @@ abstract class ModalContainer private class SavedState : BaseSavedState { constructor( - superState: Parcelable, + superState: Parcelable?, bodyState: SparseArray, dialogBundles: List ) : super(superState) { diff --git a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/ModalViewContainer.kt b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/ModalViewContainer.kt index 492bd0df3..9da621a59 100644 --- a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/ModalViewContainer.kt +++ b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/ModalViewContainer.kt @@ -52,11 +52,11 @@ internal class ModalViewContainer return Dialog(context, dialogThemeResId).apply { setCancelable(false) setContentView(modalDecorator(view)) - window.setLayout(WRAP_CONTENT, WRAP_CONTENT) + window!!.setLayout(WRAP_CONTENT, WRAP_CONTENT) if (dialogThemeResId == 0) { // If we don't set or clear the background drawable, the window cannot go full bleed. - window.setBackgroundDrawable(null) + window!!.setBackgroundDrawable(null) } show() } diff --git a/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/ParceledSnapshot.kt b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/PickledWorkflow.kt similarity index 55% rename from kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/ParceledSnapshot.kt rename to kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/PickledWorkflow.kt index f0ac4aefe..bfb54a76a 100644 --- a/kotlin/samples/tictactoe/android/src/main/java/com/squareup/sample/mainactivity/ParceledSnapshot.kt +++ b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/PickledWorkflow.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017 Square Inc. + * Copyright 2019 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,15 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.squareup.sample.mainactivity +package com.squareup.workflow.ui import android.os.Parcel import android.os.Parcelable import com.squareup.workflow.Snapshot import okio.ByteString -internal class ParceledSnapshot(val snapshot: Snapshot) : Parcelable { - +/** + * A [Parcelable] snapshot of the most recent state of a [Workflow] managed by + * [WorkflowActivityRunner], for use in an `Activity`'s persistence `Bundle`. + * See [WorkflowActivityRunner.asParcelable] for details. + */ +class PickledWorkflow(internal val snapshot: Snapshot) : Parcelable { override fun describeContents(): Int = 0 override fun writeToParcel( @@ -29,12 +33,12 @@ internal class ParceledSnapshot(val snapshot: Snapshot) : Parcelable { flags: Int ) = dest.writeByteArray(snapshot.bytes.toByteArray()) - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): ParceledSnapshot { - val bytes = parcel.createByteArray() - return ParceledSnapshot(Snapshot.of(ByteString.of(*bytes))) + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): PickledWorkflow { + val bytes = parcel.createByteArray()!! + return PickledWorkflow(Snapshot.of(ByteString.of(*bytes))) } - override fun newArray(size: Int): Array = arrayOfNulls(size) + override fun newArray(size: Int): Array = arrayOfNulls(size) } } diff --git a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowActivityRunner.kt b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowActivityRunner.kt new file mode 100644 index 000000000..8e1908897 --- /dev/null +++ b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowActivityRunner.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2019 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.squareup.workflow.ui + +import android.app.Activity +import android.arch.lifecycle.ViewModelProviders +import android.os.Parcelable +import android.support.annotation.CheckResult +import android.support.v4.app.FragmentActivity +import com.squareup.workflow.Workflow +import io.reactivex.Observable + +/** + * Packages a [Workflow] and a [ViewRegistry] to drive an [Activity][FragmentActivity]. + * + * You'll never instantiate one of these yourself. Instead, use + * [FragmentActivity.setContentWorkflow]. See that method for more details. + */ +class WorkflowActivityRunner +internal constructor(private val model: WorkflowViewModel) { + + internal val renderings: Observable = model.updates.map { it.rendering } + + val viewRegistry: ViewRegistry = model.viewRegistry + + /** + * A stream of the [output][OutputT] values emitted by the [Workflow] + * managed by this model. + */ + val output: Observable = model.updates.filter { it.output != null } + .map { it.output!! } + + /** + * Returns a [Parcelable] instance of [PickledWorkflow] to be written + * to the bundle passed to [FragmentActivity.onSaveInstanceState]. + * Read it back out in [FragmentActivity.onCreate], to serve as the + * final argument to [FragmentActivity.setContentWorkflow]. + */ + val asParcelable: Parcelable get() = PickledWorkflow(model.lastSnapshot) + + /** + * To be called from [FragmentActivity.onBackPressed], to give the managed + * [Workflow] access to back button events. + * + * e.g.: + * + * override fun onBackPressed() { + * if (!workflowViewModel.onBackPressed(this)) super.onBackPressed() + * } + */ + fun onBackPressed(activity: Activity): Boolean { + return HandlesBack.Helper.onBackPressed(activity.findViewById(R.id.workflow_layout)) + } +} + +/** + * Call this method from [FragmentActivity.onCreate], instead of [FragmentActivity.setContentView]. + * It creates a [WorkflowActivityRunner] for this activity, if one doesn't already exist, and + * sets a view driven by that model as the content view. + * + * Hold onto the [WorkflowActivityRunner] returned and: + * + * - Call [WorkflowActivityRunner.onBackPressed] from [FragmentActivity.onBackPressed] to allow + * workflows to handle back button events. (See [HandlesBack] for more details.) + * + * - Write [WorkflowActivityRunner.asParcelable] to the bundle passed to + * [FragmentActivity.onSaveInstanceState]. You'll read that [parcelable][PickledWorkflow] + * back in [FragmentActivity.onCreate], for use by the next call to this method. + * + * e.g.: + * + * class MainActivity : AppCompatActivity() { + * private lateinit var runner: WorkflowRunner<*, *> + * + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * + * val restored = savedInstanceState?.getParcelable("WORKFLOW") + * runner = setContentWorkflow(MyViewRegistry, MyRootWorkflow(), restored) + * } + * + * override fun onBackPressed() { + * if (!runner.onBackPressed(this)) super.onBackPressed() + * } + * + * override fun onSaveInstanceState(outState: Bundle) { + * super.onSaveInstanceState(outState) + * outState.putParcelable("WORKFLOW", runner.asParcelable) + * } + * } + */ +@CheckResult +fun FragmentActivity.setContentWorkflow( + viewRegistry: ViewRegistry, + workflow: Workflow, + initialInput: InputT, + restored: PickledWorkflow? +): WorkflowActivityRunner { + val factory = WorkflowViewModel.Factory(viewRegistry, workflow, initialInput, restored) + + // We use an Android lifecycle ViewModel to shield ourselves from configuration changes. + // ViewModelProviders.of() uses the factory to instantiate a new instance only + // on the first call for this activity, and it stores that instance for repeated use + // until this activity is finished. + + @Suppress("UNCHECKED_CAST") + val viewModel = ViewModelProviders.of(this, factory)[WorkflowViewModel::class.java] + as WorkflowViewModel + val runner = WorkflowActivityRunner(viewModel) + + val layout = WorkflowLayout(this@setContentWorkflow) + .apply { + id = R.id.workflow_layout + setWorkflowRunner(runner) + } + + this.setContentView(layout) + + return runner +} + +/** + * Convenience overload of [setContentWorkflow] for workflows that take no input. + */ +@CheckResult +fun FragmentActivity.setContentWorkflow( + viewRegistry: ViewRegistry, + workflow: Workflow, + restored: PickledWorkflow? +): WorkflowActivityRunner { + return setContentWorkflow(viewRegistry, workflow, Unit, restored) +} diff --git a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowLayout.kt b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowLayout.kt new file mode 100644 index 000000000..1f9c757e5 --- /dev/null +++ b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowLayout.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2019 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.squareup.workflow.ui + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.os.Parcelable.Creator +import android.util.AttributeSet +import android.util.SparseArray +import android.view.View +import android.widget.FrameLayout +import io.reactivex.Observable +import kotlin.reflect.jvm.jvmName + +internal class WorkflowLayout( + context: Context, + attributeSet: AttributeSet? = null +) : FrameLayout(context, attributeSet), HandlesBack { + private var restoredChildState: SparseArray? = null + private val showing: View? get() = if (childCount > 0) getChildAt(0) else null + + fun setWorkflowRunner(workflowRunner: WorkflowActivityRunner<*, *>) { + takeWhileAttached( + workflowRunner.renderings.distinctUntilChanged { rendering -> rendering::class }) { + show(it, workflowRunner.renderings.ofType(it::class.java), workflowRunner.viewRegistry) + } + } + + override fun onBackPressed(): Boolean { + return showing + ?.let { HandlesBack.Helper.onBackPressed(it) } + ?: false + } + + private fun show( + newRendering: Any, + renderings: Observable, + viewRegistry: ViewRegistry + ) { + removeAllViews() + val binding = viewRegistry.getBinding(newRendering::class.jvmName) + val newView = binding.buildView(renderings, viewRegistry, this) + restoredChildState?.let { restoredState -> + restoredChildState = null + newView.restoreHierarchyState(restoredState) + } + addView(newView) + } + + override fun onSaveInstanceState(): Parcelable? { + return SavedState( + super.onSaveInstanceState()!!, + SparseArray().also { array -> showing?.saveHierarchyState(array) } + ) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + (state as? SavedState) + ?.let { + restoredChildState = it.childState + super.onRestoreInstanceState(state.superState) + } + ?: super.onRestoreInstanceState(state) + } + + private class SavedState : BaseSavedState { + constructor( + superState: Parcelable?, + childState: SparseArray + ) : super(superState) { + this.childState = childState + } + + constructor(source: Parcel) : super(source) { + @Suppress("UNCHECKED_CAST") + this.childState = source.readSparseArray(SavedState::class.java.classLoader) + as SparseArray + } + + val childState: SparseArray + + override fun writeToParcel( + out: Parcel, + flags: Int + ) { + super.writeToParcel(out, flags) + @Suppress("UNCHECKED_CAST") + out.writeSparseArray(childState as SparseArray) + } + + companion object CREATOR : Creator { + override fun createFromParcel(source: Parcel): SavedState = + SavedState(source) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} diff --git a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowViewModel.kt b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowViewModel.kt new file mode 100644 index 000000000..414b617f6 --- /dev/null +++ b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/WorkflowViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2019 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.squareup.workflow.ui + +import android.arch.lifecycle.ViewModel +import android.arch.lifecycle.ViewModelProvider +import com.squareup.workflow.Snapshot +import com.squareup.workflow.Workflow +import com.squareup.workflow.WorkflowHost +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import kotlinx.coroutines.experimental.Dispatchers +import kotlinx.coroutines.experimental.rx2.asObservable + +/** + * The guts of [WorkflowActivityRunner]. We could have made that class itself a + * [ViewModel], but that would allow accidental calls to [onCleared], which + * would be nasty. + */ +internal class WorkflowViewModel( + val viewRegistry: ViewRegistry, + host: WorkflowHost +) : ViewModel() { + + internal class Factory( + private val viewRegistry: ViewRegistry, + private val workflow: Workflow, + private val initialInput: InputT, + private val restored: PickledWorkflow? + ) : ViewModelProvider.Factory { + private val hostFactory = WorkflowHost.Factory(Dispatchers.Unconfined) + + override fun create(modelClass: Class): T { + val host = hostFactory.run(workflow, initialInput, restored?.snapshot) + @Suppress("UNCHECKED_CAST") + return WorkflowViewModel(viewRegistry, host) as T + } + } + + private lateinit var sub: Disposable + + var lastSnapshot: Snapshot = Snapshot.EMPTY + + val updates = + host.updates.asObservable(Dispatchers.Unconfined) + // Unclear to me why this is necessary. + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { lastSnapshot = it.snapshot } + .replay(1) + .autoConnect(1) { sub = it } + + override fun onCleared() { + // Has the side effect of closing the updates channel, which in turn + // will fire any tear downs registered by the root workflow. + sub.dispose() + } +} diff --git a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/backstack/ViewStateFrame.kt b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/backstack/ViewStateFrame.kt index ccd7c6210..760e9ee03 100644 --- a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/backstack/ViewStateFrame.kt +++ b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/backstack/ViewStateFrame.kt @@ -22,7 +22,8 @@ import android.util.SparseArray /** * Used by [ViewStateStack] to record the [viewState] data for the view identified - * by [key], which is expected to match the `toString()` of a [BackStackScreen.key]. + * by [key], which is expected to match the `toString()` of a + * [com.squareup.workflow.ui.BackStackScreen.key]. */ internal data class ViewStateFrame( val key: String, @@ -42,7 +43,7 @@ internal data class ViewStateFrame( companion object CREATOR : Creator { override fun createFromParcel(parcel: Parcel): ViewStateFrame { - val key = parcel.readString() + val key = parcel.readString()!! @Suppress("UNCHECKED_CAST") val viewState = parcel.readSparseArray(ViewStateFrame::class.java.classLoader) diff --git a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/backstack/ViewStateStack.kt b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/backstack/ViewStateStack.kt index 980520733..f363bf1dc 100644 --- a/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/backstack/ViewStateStack.kt +++ b/kotlin/workflow-ui-android/src/main/java/com/squareup/workflow/ui/backstack/ViewStateStack.kt @@ -122,7 +122,7 @@ class ViewStateStack private constructor( val saved = SparseArray().apply { currentView.saveHierarchyState(this) } val newFrame = ViewStateFrame(currentView.backStackKey.toString(), saved) - if (!viewStates.isEmpty() && viewStates.last().key == currentView.backStackKey.toString()) { + if (viewStates.isNotEmpty() && viewStates.last().key == currentView.backStackKey.toString()) { viewStates = viewStates.subList(0, viewStates.size - 1) } viewStates += newFrame @@ -137,14 +137,14 @@ class ViewStateStack private constructor( */ class SavedState : BaseSavedState { constructor( - saving: Parcelable, + superState: Parcelable?, viewStateStack: ViewStateStack - ) : super(saving) { + ) : super(superState) { this.viewStateStack = viewStateStack } constructor(source: Parcel) : super(source) { - this.viewStateStack = source.readParcelable(SavedState::class.java.classLoader) + this.viewStateStack = source.readParcelable(SavedState::class.java.classLoader)!! } val viewStateStack: ViewStateStack diff --git a/kotlin/workflow-ui-android/src/main/res/values/ids.xml b/kotlin/workflow-ui-android/src/main/res/values/ids.xml index 645be2fde..57f77a09d 100644 --- a/kotlin/workflow-ui-android/src/main/res/values/ids.xml +++ b/kotlin/workflow-ui-android/src/main/res/values/ids.xml @@ -23,4 +23,6 @@ + +