Android Views as a Function of State with ViewBinding Case Study; The Live Game Stream
Bugdroid ❤s single line tags

TJ Dahunsi
Nov 14 2020 · 13 mins
Android Views as a Function of State with ViewBinding Case Study 1: The Live Game Stream
Bugdroid ❤s single line tags
This post is part of a series that shows how Views
in android can be represented purely as a function of some state with no side effects helping to build a robust, easy to scale and maintain app with the help of ViewBinding
.
This post is also a follow up to Building the right View Abstraction. In that post, I stated it wasn’t XML that was holding the Android View System back, but rather state dichotomy between views and the business layer. I went on further to say that having stateless views would go a long way to improving the View
writing process whether programmatically a la Jetpack Compose, or from static declarations via XML.
This post will expand on the latter part; while addressing the sentiment that static view declarations cannot easily represent dynamic content. To do this, I shall be using the basketball game stream feature we just launched at GameChanger as a case study. First let’s go over the high level feature requirements:
The basketball game stream should be able to pan between both sides of the court to represent where the action is currently happening.
The basketball game stream should be able to animate the basketball according to the play being reflected.
In situations like free throws, the backdrop should transition between images of the basketball court and the basketball hoop.
After each play, a banner (small or large) should offer a description of the play that just ended.
The basketball game stream experience
The requirements above specify highly dynamic events that are best represented with some sort of state, especially for being able to represent smooth animations for things like panning the court, and animating the basketball. The final state declaration looks like:
sealed class Target { sealed class Background : Target() { object Hoop : Target.Background() object Court : Target.Background() } sealed class Banner : Target() { object Large : Target.Banner() object Small : Target.Banner() } sealed class Ball : Target() { object A : Target.Ball() object B : Target.Ball() } } data class State( val dimensions: Action.Dimensions, val bitmapDownSampleFactor: Int, val hoopBitmap: Bitmap, val courtBitmap: Bitmap, val ballBitmapA: Bitmap? = null, val ballBitmapB: Bitmap? = null, val metaData: MetaData = MetaData(), val previousMetaData: MetaData = MetaData(), val courtTranslationX: Float = 0f, val targetAlphaMap: Map<Target, Float> = mapOf(), val targetTranslationYMap: Map<Target, Float> = mapOf(), val ballBitmapCache: LruCache<Int, Bitmap> = object : LruCache<Int, Bitmap>(3) { override fun entryRemoved(evicted: Boolean, key: Int, oldValue: Bitmap, newValue: Bitmap?) = oldValue.recycle() }, val banner: Banner? = null )
The state machine that drives the production of this state is two way bound as some state parameters are dependent on the dimensions of the phone screen, and the dimension input in turns drives the production of the bitmaps used as the background of the experience.
Next, the static definition of the components that make up this experience:
<!-- Game Stream Content --> <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:id="@+id/court_background" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="matrix" /> <ImageView android:id="@+id/hoop_background" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="matrix" /> <ImageView android:id="@+id/ball_image_A" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="matrix" /> <ImageView android:id="@+id/ball_image_B" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="matrix" /> <include android:id="@+id/hoops_large_banner" layout="@layout/viewholder_hoops_large_gamestream_card" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="30dp" /> <include android:id="@+id/hoops_small_banner" layout="@layout/viewholder_hoops_small_gamestream_card" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="right|bottom" android:layout_marginBottom="16dp" /> </FrameLayout> <!-- Large Banner Definition --> <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="24dp" android:alpha="0" android:clipChildren="false" android:clipToPadding="false" android:paddingHorizontal="32dp" tools:alpha="1"> <com.google.android.material.card.MaterialCardView android:id="@+id/card" android:layout_width="0dp" android:layout_height="100dp" android:layout_marginTop="36dp" app:cardCornerRadius="2dp" app:cardElevation="2dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/primaryText" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:elevation="10dp" android:fontFamily="@font/roboto_medium" android:gravity="center_horizontal" android:maxLines="1" android:paddingHorizontal="2dp" android:textColor="@color/off_black" android:textSize="17sp" app:layout_constrainedWidth="true" app:layout_constraintBottom_toTopOf="@+id/secondaryText" app:layout_constraintLeft_toLeftOf="@+id/card" app:layout_constraintRight_toRightOf="@+id/card" app:layout_constraintTop_toBottomOf="@+id/avatar" app:layout_constraintVertical_chainStyle="packed" tools:text="[Event]" /> <TextView android:id="@+id/secondaryText" android:layout_width="0dp" android:layout_height="wrap_content" android:elevation="10dp" android:fontFamily="@font/roboto_bold" android:gravity="center_horizontal" android:maxLines="1" android:paddingHorizontal="2dp" android:textColor="@color/off_black" android:textSize="17sp" app:layout_constrainedWidth="true" app:layout_constraintBottom_toBottomOf="@+id/card" app:layout_constraintLeft_toLeftOf="@+id/card" app:layout_constraintRight_toRightOf="@+id/card" app:layout_constraintTop_toBottomOf="@id/primaryText" tools:text="by [Player Name]" /> <com.gc.atoms.widgets.AvatarView android:id="@+id/avatar" android:layout_width="72dp" android:layout_height="72dp" android:elevation="10dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:size="large" /> </androidx.constraintlayout.widget.ConstraintLayout> <!-- Small banner definition is very similar to the large banner def -->
The above by itself is nothing special, it is just a static XML file. The true power comes when it is combined with ViewBinding
to create terse, readable two way bindings.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val viewBinding = FragmentHoopsLiveGamestreamBinding.bind(view) val courtImageView = viewBinding.courtBackground val largeBanner = viewBinding.hoopsLargeBanner val smallBanner = viewBinding.hoopsSmallBanner viewBinding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> viewModel.accept(Action.Dimensions( viewHash = System.identityHashCode(view), displaySize = SizeF( courtImageView.measuredWidth.toFloat(), courtImageView.measuredHeight.toFloat() ), largeBannerOffscreenTranslationY = -(largeBanner.root.height + largeBanner.root.marginTop).toFloat(), smallBannerOffScreenTranslationY = (smallBanner.root.height + smallBanner.root.marginBottom).toFloat() )) } }
Next up is binding the output of state to the View
. Since the intent is to describe the purely as a function of state, the following are pure functional expressions and use method references where possible to communicate the surrounding context of the the class body isn’t leaking into the binding context. The ViewModel exposes state through the state
val
which is a LiveData<State>
, and mapDistinct
is syntactic sugar around LiveData
Transformations.map()
and Transformations.distinctUntilChanged()
. Unfortunately there’s some tautology in the syntax of observing difference slices of the state to the methods that consume them, but a DSL can always be written to make it more terse.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { ... viewModel.state.apply { // Observe the court bitmap first before observing the matrix mapDistinct(State::courtBitmap).observe(viewLifecycleOwner, courtImageView::setImageBitmap) mapDistinct(State::courtAlpha).observe(viewLifecycleOwner, courtImageView::setVisualAlpha) mapDistinct(State::courtMatrix).observe(viewLifecycleOwner, courtImageView::updateMatrix) mapDistinct(State::hoopBitmap).observe(viewLifecycleOwner, hoopImageView::setImageBitmap) mapDistinct(State::hoopAlpha).observe(viewLifecycleOwner, hoopImageView::setVisualAlpha) mapDistinct(State::hoopMatrix).observe(viewLifecycleOwner, hoopImageView::updateMatrix) mapDistinct(State::ballMirrored).observe(viewLifecycleOwner, ballImageViewA::setScaleX) mapDistinct(State::ballImageAlphaA).observe(viewLifecycleOwner, ballImageViewA::setVisualAlpha) mapDistinct(State::ballBitmapA).observe(viewLifecycleOwner) { it?.let(ballImageViewA::setImageBitmap) } mapDistinct(State::ballMatrixA).observe(viewLifecycleOwner) { it?.let(ballImageViewA::updateMatrix) } mapDistinct(State::ballMirrored).observe(viewLifecycleOwner, ballImageViewB::setScaleX) mapDistinct(State::ballImageAlphaB).observe(viewLifecycleOwner, ballImageViewB::setVisualAlpha) mapDistinct(State::ballBitmapB).observe(viewLifecycleOwner) { it?.let(ballImageViewB::setImageBitmap) } mapDistinct(State::ballMatrixB).observe(viewLifecycleOwner) { it?.let(ballImageViewB::updateMatrix) } mapDistinct(State::largeBannerAlpha).observe(viewLifecycleOwner, largeBanner.root::setVisualAlpha) mapDistinct(State::smallBannerAlpha).observe(viewLifecycleOwner, smallBanner.root::setVisualAlpha) mapDistinct(State::largeBannerTranslationY).observe(viewLifecycleOwner, largeBanner.root::setTranslationY) mapDistinct(State::smallBannerTranslationY).observe(viewLifecycleOwner, smallBanner.root::setTranslationY) mapDistinct(State::banner).observe(viewLifecycleOwner) { it?.let { if (it.isLarge) largeBanner.bind(it) else smallBanner.bind(it) } } } }
This is practically the entirety of the Fragment
that powers the basketball game stream. It is also worth taking a closer look at the implementation of line 28:
private fun ViewholderHoopsSmallGamestreamCardBinding.bind(banner: Banner) = run { avatar.isVisible = banner.avatar != null secondaryText.isVisible = banner.bottomText != null avatar.config = banner.avatar primaryText.text = banner.topText secondaryText.text = banner.bottomText primaryText.gravity = if (banner.avatar == null) Gravity.CENTER else Gravity.START } private fun ViewholderHoopsLargeGamestreamCardBinding.bind(banner: Banner) = run { avatar.isVisible = banner.avatar != null secondaryText.isVisible = banner.bottomText != null avatar.config = banner.avatar primaryText.text = banner.topText secondaryText.text = banner.bottomText } private fun ImageView.updateMatrix(matrixDef: MatrixDef) { val tuple = matrixTuple // Swap matrix being updated to get around reference checks val matrixToUpdate = if (imageMatrix == tuple.a) tuple.b else tuple.a imageMatrix = matrixToUpdate.apply { reset() setScale(matrixDef.scale, matrixDef.scale) postTranslate(matrixDef.translationX, matrixDef.translationY) } } private val ImageView.matrixTuple get() = tag as? MatrixTuple ?: MatrixTuple().also { tag = it } /** * ImageViews use reference equality instead of object equality checks when updating their matrix, * so we keep a nice tuple and swap the matrix when we want to update to get around this. * * A [Pair] would have worked as well, but the generic bounds make for ugly casting in use. */ data class MatrixTuple( val a: Matrix = Matrix(), val b: Matrix = Matrix() )
Both the large and small banner are themselves ViewBinding
types of ViewholderHoopsLargeGamestreamCardBinding
and ViewholderHoopsSmallGamestreamCardBinding
respectively. Their state is the Banner
data class, which is a slice of the larger State
class; just like Jetpack Compose would have you aggregate smaller Composables
to “compose” them into larger ones, all with strongly typed, statically defined layouts.
Importantly, the following all hold true:
There are no reentrant side effects that attempt to mutate the
View
after its declaration and binding.The
View
is declared statically, separating the declaration of the appearance of the View from the logic of driving the View behavior.There is a 1:1 relationship between the
ViewBinding
andState
types,FragmentHoopsLiveGamestreamBinding
can be reused anywhere theState
is available; subclassing aView
to create a custom view for business logic is not necessary.The
View
is extremely simple; all the complexity is purely in the business layer as the state machine is responsible for driving each animation frame.
The last piece is how the ViewModel
is able to drive these animations. I have waxed lyrical and extolled at length the virtues of the Android Dynamic Animation Library, key of which is its ability to be retarget ongoing animations. The above experience is powered by SpringAnimation
instances, and specifying the final value the following properties should arrive at:
Alpha
TranslationX
TranslationY
The emissions of the spring can then be piped into a reactive stream, and finally converted to LiveData
for consumption by the View
.
/** * Reads existing values from the current [State], configures a [SpringForce] with the specified * params using the [springAnimationOf] extension, and completes when the animation ends. */ private fun Observable<State>.springObservable( stiffness: Float, finalPosition: (State) -> Float, getter: (State) -> Float, setter: (Float) -> Mutation, shortCircuit: (State) -> Boolean = { false } ): Observable<Mutation> = take(1).flatMap { state -> // Read the latest state first if (shortCircuit(state)) Observable.just(setter(finalPosition(state))) else Observable.create<Mutation> { emitter -> springAnimationOf( setter = { emitter.onNext(setter(it)) }, getter = { getter(state) }, finalPosition = 0f ).apply { spring = SpringForce().apply { this.dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY this.stiffness = stiffness } addEndListener { _, _, _, _ -> emitter.onComplete() } animateToFinalPosition(finalPosition(state)) } } .subscribeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread()) } sealed class Mutation { data class CourtPan(val x: Float) : Mutation() data class SetMetaData(val isFreeThrow: Boolean, val ownTeamInPossession: Boolean?) : Mutation() data class SetBanner(val banner: Banner?) : Mutation() data class SetBallBitmap(val bitmap: Bitmap?, val target: Target.Ball) : Mutation() data class UpdateBitmapCache(val entry: Pair<Int, Bitmap>) : Mutation() data class Alpha(val alpha: Float, val target: Target) : Mutation() data class TranslationY(val translation: Float, val target: Target) : Mutation() data class AnimationComplete(val itemId: String) : Mutation() object ClearBitmaps : Mutation() } fun State.reduce(mutation: Mutation): State = when (mutation) { is Mutation.CourtPan -> copy(courtTranslationX = mutation.x) is Mutation.Alpha -> copy(targetAlphaMap = HashMap(targetAlphaMap).apply { this[mutation.target] = mutation.alpha }) is Mutation.TranslationY -> copy(targetTranslationYMap = HashMap(targetTranslationYMap).apply { this[mutation.target] = mutation.translation }) is Mutation.SetBanner -> copy(banner = mutation.banner) is Mutation.SetBallBitmap -> when (mutation.target) { Target.Ball.A -> copy(ballBitmapA = mutation.bitmap) Target.Ball.B -> copy(ballBitmapB = mutation.bitmap) } is Mutation.SetMetaData -> copy( metaData = MetaData( isFreeThrow = mutation.isFreeThrow, ownTeamInPossession = mutation.ownTeamInPossession ?: metaData.ownTeamInPossession ), previousMetaData = MetaData( isFreeThrow = metaData.isFreeThrow, ownTeamInPossession = metaData.ownTeamInPossession ) ) is Mutation.UpdateBitmapCache -> apply { val (key, bitmap) = mutation.entry ballBitmapCache.put(key, bitmap) } is Mutation.ClearBitmaps -> apply { ballBitmapCache.evictAll() } is Mutation.AnimationComplete -> this // No op }
With the above each play or action in the game stream creates a Mutation
, which in turn modifies a slice of the State
. Notice the statelessness of the Mutations
; each just overrides a value in the state, and each Mutation
creates a brand new SpringAnimation
instance. I just need the initial value to animate from, which is read from the state, and specify the final value.
This statelessness also carries on to the View
layer, with each UI element is bound once, and only once to the slice of State
it cares about. The above illustrates:
Immutability / Statelessness is extremely powerful while being incredibly simple.
Complex UI absolutely can be represented with static
View
declarations. A follow up case study will cover the case where nodes in the UI tree need to be removed as part of mutations in state, and how static View declarations are not an obstacle to this.Using
ViewBinding
as a bridge, it becomes easy to aggregate a collection ofViews
into typed aggregates, and compose behaviors unto them without having to subclass theView
class.
Feel free to try out the game stream yourself! It’s free to create and score a game on your device, you will need another device to see the animated game stream in real time however:
The full implementation of the ViewModel
that drives the experience and its animation mapping follows below. It’s fairly heavy on rx, but not unfamiliar:
private typealias StartPayload = Pair<List<ScoringItem>, State> val StartPayload.seed get() = Action.Cursor( isFirst = true, index = first.lastIndex, itemId = first[first.lastIndex].diffId ) private data class AnimationInput( val cursor: Action.Cursor, val plays: List<ScoringItem>, val state: State ) private val AnimationInput.key get() = cursor private val AnimationInput.currentItem get() = plays[cursor.index] private val AnimationInput.banner get() = currentItem.toBanner private val AnimationInput.animatable get() = if (cursor.isFirst) currentItem.initialAnimatable else currentItem.toAnimatable private typealias AnimationOutput = Triple<Mutation.AnimationComplete, Action.Cursor, List<ScoringItem>> val AnimationOutput.key get() = first val AnimationOutput.cursor get() = second val AnimationOutput.plays get() = third val AnimationOutput.canStartNext get() = cursor.index < plays.lastIndex val AnimationOutput.showLatestState get() = !canStartNext && first.itemId == cursor.itemId val AnimationOutput.nextCursor get() = Action.Cursor( isFirst = false, index = cursor.index + 1, itemId = plays[cursor.index + 1].diffId ) class HoopsLiveGameStreamViewModel @Inject constructor( private val controller: HoopsGameStreamController, private val resources: Resources ) : ViewModel() { private val disposable = CompositeDisposable() private val actionRelay: BehaviorRelay<Action> = BehaviorRelay.create() private val mutationRelay: BehaviorRelay<Mutation> = BehaviorRelay.createDefault(Mutation.CourtPan(0f)) private val backingState: Observable<State> = actionRelay.filterIsInstance<Action.Dimensions>() // While the UI settles, the dimensions can change frequently, causing us to create and load // multiple background images in memory at once. The operations on the dimensions wait till // the UI is completely settled before committing one way or the other .filter(Action.Dimensions::isValid) .scan(Action.Dimensions::compare) .debounce(200, TimeUnit.MILLISECONDS) .filter(Action.Dimensions::isSameView) .distinct(Action.Dimensions::displaySize) .flatMapSingle(resources::state) .switchMap { state -> mutationRelay.distinctUntilChanged().scan(state, State::reduce) } .replayingShare() private val cursorObservable: Observable<Action.Cursor> = actionRelay.filterIsInstance<Action.Cursor>() .replayingShare() private val playsObservable: Observable<List<ScoringItem>> = controller.latestScorekeepingState .map { state -> state.state.plaByPlay.map { it.convert(state.ownTeam, state.oppTeam, resources, controller.scope) } } .filter(List<ScoringItem>::isNotEmpty) .distinctUntilChanged() .replayingShare() val state: LiveData<State> = backingState.toLiveData() init { // Run every animation as a sequence one after the other Observables.combineLatest( cursorObservable, playsObservable, backingState, ::AnimationInput ) .distinctUntilChanged(AnimationInput::key) .flatMapSingle(this::preAnimate) .map(AnimationInput::animatable) .concatMap(backingState::animate) .subscribe(mutationRelay::accept) .addTo(disposable) val completions: Observable<AnimationOutput> = Observables.combineLatest( mutationRelay.filterIsInstance<Mutation.AnimationComplete>(), cursorObservable, playsObservable ).replayingShare() // When an animation completes, and there are more items to animate, start the next one completions .filter(AnimationOutput::canStartNext) .distinctUntilChanged(AnimationOutput::key) .map(AnimationOutput::nextCursor) .subscribe(actionRelay::accept) .addTo(disposable) // When an animation completes, and there are no more items to animate, show the latest state completions .filter(AnimationOutput::showLatestState) .distinctUntilChanged(AnimationOutput::key) .subscribe { controller.currentEvent = null } // Show latest state .addTo(disposable) // Start when plays are available and court has been measured Observables.combineLatest( playsObservable, backingState ) .firstOrError() .map(StartPayload::seed) .subscribe(actionRelay::accept) .addTo(disposable) actionRelay.filterIsInstance<Action.ViewDestroyed>() .subscribe { mutationRelay.accept(Mutation.ClearBitmaps) } .addTo(disposable) } fun accept(input: Action) = actionRelay.accept(input) override fun onCleared() = disposable.clear() private fun preAnimate(input: AnimationInput): Single<AnimationInput> { if (input.currentItem.event != null) controller.currentEvent = input.currentItem.event mutationRelay.accept(Mutation.SetBanner(input.banner)) mutationRelay.accept(Mutation.TranslationY(0f, Target.Ball.A)) mutationRelay.accept(Mutation.TranslationY(0f, Target.Ball.B)) mutationRelay.accept(Mutation.SetMetaData( isFreeThrow = input.currentItem.isFreeThrow, ownTeamInPossession = input.currentItem.ownTeamInPossession )) return when (val playCode = (input.currentItem as? ScoringItem.PlayByPlayEntry)?.playCode) { null -> Single.just(input) else -> resources.cacheImages(input.state, playCode.ballDrawableResources).andThen(Single.just(input)) } } private fun Resources.cacheImages(state: State, drawableResources: List<Int>): Completable = Completable.merge(drawableResources.map { id -> if (state.ballBitmapCache[id] != null) Completable.complete() else loadBitMap(id, state.bitmapDownSampleFactor) .doOnSuccess { bitmap -> mutationRelay.accept(Mutation.UpdateBitmapCache(id to bitmap)) }.ignoreElement() }) } private fun Resources.state(dimensions: Action.Dimensions) = Single.fromCallable { // Original court image is 5000 * 2238; the display height is larger so we'll use that instead of the width val bitmapDownSampleFactor = max(2, (5000 / dimensions.displaySize.height).toInt()) val courtBitmap = downSampleBitmap(this, R.drawable.bg_hoops_court, bitmapDownSampleFactor) val hoopBitmap = downSampleBitmap(this, R.drawable.bg_hoops_ft, bitmapDownSampleFactor) State( dimensions = dimensions, bitmapDownSampleFactor = bitmapDownSampleFactor, courtBitmap = courtBitmap, hoopBitmap = hoopBitmap ) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .cache() private fun Resources.loadBitMap(drawableRes: Int, bitmapDownSampleFactor: Int) = Single.fromCallable { downSampleBitmap(this, drawableRes, bitmapDownSampleFactor) } sealed class Animatable { abstract val isNested: Boolean /** * Used to identify the [ScoringItem] being animated */ abstract val itemId: String /** * Pans the court from one end to the other */ data class CourtPan( override val isNested: Boolean = false, override val itemId: String = "", val position: Position ) : Animatable() { sealed class Position { object Left : Position() object Right : Position() object Opposite : Position() object Unchanged : Position() } } /** * Fades an item in or out */ data class Alpha( override val isNested: Boolean = false, override val itemId: String = "", val springStiffness: Float = SpringForce.STIFFNESS_VERY_LOW, val target: Target, val opacity: Float = 1.0f ) : Animatable() /** * Moves an item up or down */ data class VerticalTranslation( override val isNested: Boolean = false, override val itemId: String = "", val springStiffness: Float = SpringForce.STIFFNESS_LOW, val target: Target, val atNaturalPosition: Boolean ) : Animatable() /** * Sets a ball image into a target */ data class BallImage( override val isNested: Boolean = false, override val itemId: String = "", val imageResource: Int?, val target: Target.Ball ) : Animatable() /** * Runs the [nested] animations consecutively */ data class Series( override val isNested: Boolean = false, override val itemId: String = "", val nested: List<Animatable> ) : Animatable() /** * Runs the [nested] animations simultaneously */ data class Parallel( override val isNested: Boolean = false, override val itemId: String = "", val nested: List<Animatable> ) : Animatable() /** * Delays for [millis] milliseconds */ data class Delay( override val isNested: Boolean = false, override val itemId: String = "", val millis: Long ) : Animatable() /** * Does not animate at all */ data class Drop( override val isNested: Boolean = false, override val itemId: String = "" ) : Animatable() } /** * Creates a [Mutation] stream as the result of running the [animatable] on the [State] * and notifies of the completion. */ fun Observable<State>.animate(animatable: Animatable): Observable<Mutation> = when (animatable) { is Animatable.CourtPan -> springObservable( stiffness = SpringForce.STIFFNESS_VERY_LOW, finalPosition = { it.courtTranslationX(animatable.position) }, getter = State::courtTranslationX, setter = { Mutation.CourtPan(x = it) }, // Do not animate if the court it not visible, just snap into place, // used for if there was a free throw background showing prior shortCircuit = { currentState: State -> currentState.previousMetaData.isFreeThrow } ) is Animatable.Alpha -> springObservable( stiffness = animatable.springStiffness, finalPosition = { 100f * animatable.opacity }, getter = { it.targetAlphaMap.getValue(animatable.target) * 100 }, setter = { Mutation.Alpha(alpha = it / 100, target = animatable.target) } ) is Animatable.VerticalTranslation -> springObservable( stiffness = animatable.springStiffness, finalPosition = { if (animatable.atNaturalPosition) 0f else when (val target = animatable.target) { is Target.Banner.Large -> it.dimensions.largeBannerOffscreenTranslationY is Target.Banner.Small -> it.dimensions.smallBannerOffScreenTranslationY is Target.Ball.A -> it.ballDropTranslationY(target) is Target.Ball.B -> it.ballDropTranslationY(target) is Target.Background.Court, Target.Background.Hoop -> 0f } }, getter = { it.targetTranslationYMap.getValue(animatable.target) }, setter = { Mutation.TranslationY(translation = it, target = animatable.target) } ) is Animatable.BallImage -> ballImageObservable(animatable.imageResource, animatable.target) is Animatable.Series -> Observable.concat(animatable.nested.map(Animatable::asNested).map(this::animate)) is Animatable.Parallel -> Observable.merge(animatable.nested.map(Animatable::asNested).map(this::animate)) is Animatable.Delay -> Observable.timer(animatable.millis, TimeUnit.MILLISECONDS, delayScheduler).flatMap { Observable.empty() } is Animatable.Drop -> Observable.empty() }.concatWith(if (animatable.isNested) Observable.empty() else Observable.just(Mutation.AnimationComplete(animatable.itemId)))