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 min read
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:
1sealed class Target { 2 sealed class Background : Target() { 3 object Hoop : Target.Background() 4 object Court : Target.Background() 5 } 6 7 sealed class Banner : Target() { 8 object Large : Target.Banner() 9 object Small : Target.Banner() 10 } 11 12 sealed class Ball : Target() { 13 object A : Target.Ball() 14 object B : Target.Ball() 15 } 16} 17 18data class State( 19 val dimensions: Action.Dimensions, 20 val bitmapDownSampleFactor: Int, 21 val hoopBitmap: Bitmap, 22 val courtBitmap: Bitmap, 23 val ballBitmapA: Bitmap? = null, 24 val ballBitmapB: Bitmap? = null, 25 val metaData: MetaData = MetaData(), 26 val previousMetaData: MetaData = MetaData(), 27 val courtTranslationX: Float = 0f, 28 val targetAlphaMap: Map<Target, Float> = mapOf(), 29 val targetTranslationYMap: Map<Target, Float> = mapOf(), 30 val ballBitmapCache: LruCache<Int, Bitmap> = object : LruCache<Int, Bitmap>(3) { 31 override fun entryRemoved(evicted: Boolean, key: Int, oldValue: Bitmap, newValue: Bitmap?) = 32 oldValue.recycle() 33 }, 34 val banner: Banner? = null 35)
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:
1<!-- Game Stream Content --> 2 3<?xml version="1.0" encoding="utf-8"?> 4<FrameLayout 5 xmlns:android="http://schemas.android.com/apk/res/android" 6 android:layout_width="match_parent" 7 android:layout_height="match_parent"> 8 9 <ImageView 10 android:id="@+id/court_background" 11 android:layout_width="match_parent" 12 android:layout_height="match_parent" 13 android:scaleType="matrix" /> 14 15 <ImageView 16 android:id="@+id/hoop_background" 17 android:layout_width="match_parent" 18 android:layout_height="match_parent" 19 android:scaleType="matrix" /> 20 21 <ImageView 22 android:id="@+id/ball_image_A" 23 android:layout_width="match_parent" 24 android:layout_height="match_parent" 25 android:scaleType="matrix" /> 26 27 <ImageView 28 android:id="@+id/ball_image_B" 29 android:layout_width="match_parent" 30 android:layout_height="match_parent" 31 android:scaleType="matrix" /> 32 33 <include 34 android:id="@+id/hoops_large_banner" 35 layout="@layout/viewholder_hoops_large_gamestream_card" 36 android:layout_width="match_parent" 37 android:layout_height="wrap_content" 38 android:layout_gravity="top" 39 android:layout_marginTop="30dp" /> 40 41 <include 42 android:id="@+id/hoops_small_banner" 43 layout="@layout/viewholder_hoops_small_gamestream_card" 44 android:layout_width="match_parent" 45 android:layout_height="wrap_content" 46 android:layout_gravity="right|bottom" 47 android:layout_marginBottom="16dp" /> 48 49</FrameLayout> 50 51<!-- Large Banner Definition --> 52 53<?xml version="1.0" encoding="utf-8"?> 54<androidx.constraintlayout.widget.ConstraintLayout 55 xmlns:android="http://schemas.android.com/apk/res/android" 56 xmlns:app="http://schemas.android.com/apk/res-auto" 57 xmlns:tools="http://schemas.android.com/tools" 58 android:layout_width="match_parent" 59 android:layout_height="wrap_content" 60 android:layout_gravity="center_horizontal" 61 android:layout_marginTop="24dp" 62 android:alpha="0" 63 android:clipChildren="false" 64 android:clipToPadding="false" 65 android:paddingHorizontal="32dp" 66 tools:alpha="1"> 67 68 <com.google.android.material.card.MaterialCardView 69 android:id="@+id/card" 70 android:layout_width="0dp" 71 android:layout_height="100dp" 72 android:layout_marginTop="36dp" 73 app:cardCornerRadius="2dp" 74 app:cardElevation="2dp" 75 app:layout_constraintBottom_toBottomOf="parent" 76 app:layout_constraintLeft_toLeftOf="parent" 77 app:layout_constraintRight_toRightOf="parent" 78 app:layout_constraintTop_toTopOf="parent" /> 79 80 <TextView 81 android:id="@+id/primaryText" 82 android:layout_width="0dp" 83 android:layout_height="wrap_content" 84 android:layout_marginTop="8dp" 85 android:elevation="10dp" 86 android:fontFamily="@font/roboto_medium" 87 android:gravity="center_horizontal" 88 android:maxLines="1" 89 android:paddingHorizontal="2dp" 90 android:textColor="@color/off_black" 91 android:textSize="17sp" 92 app:layout_constrainedWidth="true" 93 app:layout_constraintBottom_toTopOf="@+id/secondaryText" 94 app:layout_constraintLeft_toLeftOf="@+id/card" 95 app:layout_constraintRight_toRightOf="@+id/card" 96 app:layout_constraintTop_toBottomOf="@+id/avatar" 97 app:layout_constraintVertical_chainStyle="packed" 98 tools:text="[Event]" /> 99 100 <TextView 101 android:id="@+id/secondaryText" 102 android:layout_width="0dp" 103 android:layout_height="wrap_content" 104 android:elevation="10dp" 105 android:fontFamily="@font/roboto_bold" 106 android:gravity="center_horizontal" 107 android:maxLines="1" 108 android:paddingHorizontal="2dp" 109 android:textColor="@color/off_black" 110 android:textSize="17sp" 111 app:layout_constrainedWidth="true" 112 app:layout_constraintBottom_toBottomOf="@+id/card" 113 app:layout_constraintLeft_toLeftOf="@+id/card" 114 app:layout_constraintRight_toRightOf="@+id/card" 115 app:layout_constraintTop_toBottomOf="@id/primaryText" 116 tools:text="by [Player Name]" /> 117 118 119 <com.gc.atoms.widgets.AvatarView 120 android:id="@+id/avatar" 121 android:layout_width="72dp" 122 android:layout_height="72dp" 123 android:elevation="10dp" 124 app:layout_constraintEnd_toEndOf="parent" 125 app:layout_constraintStart_toStartOf="parent" 126 app:layout_constraintTop_toTopOf="parent" 127 app:size="large" /> 128 129</androidx.constraintlayout.widget.ConstraintLayout> 130 131<!-- 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.
1override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 2 super.onViewCreated(view, savedInstanceState) 3 4 val viewBinding = FragmentHoopsLiveGamestreamBinding.bind(view) 5 val courtImageView = viewBinding.courtBackground 6 val largeBanner = viewBinding.hoopsLargeBanner 7 val smallBanner = viewBinding.hoopsSmallBanner 8 9 viewBinding.root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> 10 viewModel.accept(Action.Dimensions( 11 viewHash = System.identityHashCode(view), 12 displaySize = SizeF( 13 courtImageView.measuredWidth.toFloat(), 14 courtImageView.measuredHeight.toFloat() 15 ), 16 largeBannerOffscreenTranslationY = -(largeBanner.root.height + largeBanner.root.marginTop).toFloat(), 17 smallBannerOffScreenTranslationY = (smallBanner.root.height + smallBanner.root.marginBottom).toFloat() 18 )) 19 } 20 }
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.
1override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 2 ... 3 4 viewModel.state.apply { 5 // Observe the court bitmap first before observing the matrix 6 mapDistinct(State::courtBitmap).observe(viewLifecycleOwner, courtImageView::setImageBitmap) 7 mapDistinct(State::courtAlpha).observe(viewLifecycleOwner, courtImageView::setVisualAlpha) 8 mapDistinct(State::courtMatrix).observe(viewLifecycleOwner, courtImageView::updateMatrix) 9 10 mapDistinct(State::hoopBitmap).observe(viewLifecycleOwner, hoopImageView::setImageBitmap) 11 mapDistinct(State::hoopAlpha).observe(viewLifecycleOwner, hoopImageView::setVisualAlpha) 12 mapDistinct(State::hoopMatrix).observe(viewLifecycleOwner, hoopImageView::updateMatrix) 13 14 mapDistinct(State::ballMirrored).observe(viewLifecycleOwner, ballImageViewA::setScaleX) 15 mapDistinct(State::ballImageAlphaA).observe(viewLifecycleOwner, ballImageViewA::setVisualAlpha) 16 mapDistinct(State::ballBitmapA).observe(viewLifecycleOwner) { it?.let(ballImageViewA::setImageBitmap) } 17 mapDistinct(State::ballMatrixA).observe(viewLifecycleOwner) { it?.let(ballImageViewA::updateMatrix) } 18 19 mapDistinct(State::ballMirrored).observe(viewLifecycleOwner, ballImageViewB::setScaleX) 20 mapDistinct(State::ballImageAlphaB).observe(viewLifecycleOwner, ballImageViewB::setVisualAlpha) 21 mapDistinct(State::ballBitmapB).observe(viewLifecycleOwner) { it?.let(ballImageViewB::setImageBitmap) } 22 mapDistinct(State::ballMatrixB).observe(viewLifecycleOwner) { it?.let(ballImageViewB::updateMatrix) } 23 24 mapDistinct(State::largeBannerAlpha).observe(viewLifecycleOwner, largeBanner.root::setVisualAlpha) 25 mapDistinct(State::smallBannerAlpha).observe(viewLifecycleOwner, smallBanner.root::setVisualAlpha) 26 mapDistinct(State::largeBannerTranslationY).observe(viewLifecycleOwner, largeBanner.root::setTranslationY) 27 mapDistinct(State::smallBannerTranslationY).observe(viewLifecycleOwner, smallBanner.root::setTranslationY) 28 mapDistinct(State::banner).observe(viewLifecycleOwner) { it?.let { if (it.isLarge) largeBanner.bind(it) else smallBanner.bind(it) } } 29 } 30 }
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:
1private fun ViewholderHoopsSmallGamestreamCardBinding.bind(banner: Banner) = run { 2 avatar.isVisible = banner.avatar != null 3 secondaryText.isVisible = banner.bottomText != null 4 avatar.config = banner.avatar 5 primaryText.text = banner.topText 6 secondaryText.text = banner.bottomText 7 primaryText.gravity = if (banner.avatar == null) Gravity.CENTER else Gravity.START 8} 9 10private fun ViewholderHoopsLargeGamestreamCardBinding.bind(banner: Banner) = run { 11 avatar.isVisible = banner.avatar != null 12 secondaryText.isVisible = banner.bottomText != null 13 avatar.config = banner.avatar 14 primaryText.text = banner.topText 15 secondaryText.text = banner.bottomText 16} 17 18private fun ImageView.updateMatrix(matrixDef: MatrixDef) { 19 val tuple = matrixTuple 20 // Swap matrix being updated to get around reference checks 21 val matrixToUpdate = if (imageMatrix == tuple.a) tuple.b else tuple.a 22 imageMatrix = matrixToUpdate.apply { 23 reset() 24 setScale(matrixDef.scale, matrixDef.scale) 25 postTranslate(matrixDef.translationX, matrixDef.translationY) 26 } 27} 28 29private val ImageView.matrixTuple get() = tag as? MatrixTuple ?: MatrixTuple().also { tag = it } 30 31/** 32 * ImageViews use reference equality instead of object equality checks when updating their matrix, 33 * so we keep a nice tuple and swap the matrix when we want to update to get around this. 34 * 35 * A [Pair] would have worked as well, but the generic bounds make for ugly casting in use. 36 */ 37data class MatrixTuple( 38 val a: Matrix = Matrix(), 39 val b: Matrix = Matrix() 40)
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
.
1/** 2 * Reads existing values from the current [State], configures a [SpringForce] with the specified 3 * params using the [springAnimationOf] extension, and completes when the animation ends. 4 */ 5private fun Observable<State>.springObservable( 6 stiffness: Float, 7 finalPosition: (State) -> Float, 8 getter: (State) -> Float, 9 setter: (Float) -> Mutation, 10 shortCircuit: (State) -> Boolean = { false } 11): Observable<Mutation> = take(1).flatMap { state -> // Read the latest state first 12 if (shortCircuit(state)) Observable.just(setter(finalPosition(state))) 13 else Observable.create<Mutation> { emitter -> 14 springAnimationOf( 15 setter = { emitter.onNext(setter(it)) }, 16 getter = { getter(state) }, 17 finalPosition = 0f 18 ).apply { 19 spring = SpringForce().apply { 20 this.dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY 21 this.stiffness = stiffness 22 } 23 addEndListener { _, _, _, _ -> emitter.onComplete() } 24 animateToFinalPosition(finalPosition(state)) 25 } 26 } 27 .subscribeOn(AndroidSchedulers.mainThread()) 28 .observeOn(AndroidSchedulers.mainThread()) 29} 30 31sealed class Mutation { 32 data class CourtPan(val x: Float) : Mutation() 33 data class SetMetaData(val isFreeThrow: Boolean, val ownTeamInPossession: Boolean?) : Mutation() 34 data class SetBanner(val banner: Banner?) : Mutation() 35 data class SetBallBitmap(val bitmap: Bitmap?, val target: Target.Ball) : Mutation() 36 data class UpdateBitmapCache(val entry: Pair<Int, Bitmap>) : Mutation() 37 data class Alpha(val alpha: Float, val target: Target) : Mutation() 38 data class TranslationY(val translation: Float, val target: Target) : Mutation() 39 data class AnimationComplete(val itemId: String) : Mutation() 40 object ClearBitmaps : Mutation() 41} 42 43fun State.reduce(mutation: Mutation): State = when (mutation) { 44 is Mutation.CourtPan -> copy(courtTranslationX = mutation.x) 45 is Mutation.Alpha -> copy(targetAlphaMap = HashMap(targetAlphaMap).apply { 46 this[mutation.target] = mutation.alpha 47 }) 48 is Mutation.TranslationY -> copy(targetTranslationYMap = HashMap(targetTranslationYMap).apply { 49 this[mutation.target] = mutation.translation 50 }) 51 is Mutation.SetBanner -> copy(banner = mutation.banner) 52 is Mutation.SetBallBitmap -> when (mutation.target) { 53 Target.Ball.A -> copy(ballBitmapA = mutation.bitmap) 54 Target.Ball.B -> copy(ballBitmapB = mutation.bitmap) 55 } 56 is Mutation.SetMetaData -> copy( 57 metaData = MetaData( 58 isFreeThrow = mutation.isFreeThrow, 59 ownTeamInPossession = mutation.ownTeamInPossession ?: metaData.ownTeamInPossession 60 ), 61 previousMetaData = MetaData( 62 isFreeThrow = metaData.isFreeThrow, 63 ownTeamInPossession = metaData.ownTeamInPossession 64 ) 65 ) 66 is Mutation.UpdateBitmapCache -> apply { 67 val (key, bitmap) = mutation.entry 68 ballBitmapCache.put(key, bitmap) 69 } 70 is Mutation.ClearBitmaps -> apply { 71 ballBitmapCache.evictAll() 72 } 73 is Mutation.AnimationComplete -> this // No op 74}
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:
1private typealias StartPayload = Pair<List<ScoringItem>, State> 2 3val StartPayload.seed 4 get() = Action.Cursor( 5 isFirst = true, 6 index = first.lastIndex, 7 itemId = first[first.lastIndex].diffId 8 ) 9 10private data class AnimationInput( 11 val cursor: Action.Cursor, 12 val plays: List<ScoringItem>, 13 val state: State 14) 15 16private val AnimationInput.key get() = cursor 17private val AnimationInput.currentItem get() = plays[cursor.index] 18private val AnimationInput.banner get() = currentItem.toBanner 19private val AnimationInput.animatable get() = if (cursor.isFirst) currentItem.initialAnimatable else currentItem.toAnimatable 20 21private typealias AnimationOutput = Triple<Mutation.AnimationComplete, Action.Cursor, List<ScoringItem>> 22 23val AnimationOutput.key get() = first 24val AnimationOutput.cursor get() = second 25val AnimationOutput.plays get() = third 26val AnimationOutput.canStartNext get() = cursor.index < plays.lastIndex 27val AnimationOutput.showLatestState get() = !canStartNext && first.itemId == cursor.itemId 28val AnimationOutput.nextCursor 29 get() = Action.Cursor( 30 isFirst = false, 31 index = cursor.index + 1, 32 itemId = plays[cursor.index + 1].diffId 33 ) 34 35class HoopsLiveGameStreamViewModel @Inject constructor( 36 private val controller: HoopsGameStreamController, 37 private val resources: Resources 38) : ViewModel() { 39 40 private val disposable = CompositeDisposable() 41 private val actionRelay: BehaviorRelay<Action> = BehaviorRelay.create() 42 private val mutationRelay: BehaviorRelay<Mutation> = BehaviorRelay.createDefault(Mutation.CourtPan(0f)) 43 private val backingState: Observable<State> = actionRelay.filterIsInstance<Action.Dimensions>() 44 // While the UI settles, the dimensions can change frequently, causing us to create and load 45 // multiple background images in memory at once. The operations on the dimensions wait till 46 // the UI is completely settled before committing one way or the other 47 .filter(Action.Dimensions::isValid) 48 .scan(Action.Dimensions::compare) 49 .debounce(200, TimeUnit.MILLISECONDS) 50 .filter(Action.Dimensions::isSameView) 51 .distinct(Action.Dimensions::displaySize) 52 .flatMapSingle(resources::state) 53 .switchMap { state -> mutationRelay.distinctUntilChanged().scan(state, State::reduce) } 54 .replayingShare() 55 private val cursorObservable: Observable<Action.Cursor> = actionRelay.filterIsInstance<Action.Cursor>() 56 .replayingShare() 57 private val playsObservable: Observable<List<ScoringItem>> = controller.latestScorekeepingState 58 .map { state -> state.state.plaByPlay.map { it.convert(state.ownTeam, state.oppTeam, resources, controller.scope) } } 59 .filter(List<ScoringItem>::isNotEmpty) 60 .distinctUntilChanged() 61 .replayingShare() 62 63 val state: LiveData<State> = backingState.toLiveData() 64 65 init { 66 // Run every animation as a sequence one after the other 67 Observables.combineLatest( 68 cursorObservable, 69 playsObservable, 70 backingState, 71 ::AnimationInput 72 ) 73 .distinctUntilChanged(AnimationInput::key) 74 .flatMapSingle(this::preAnimate) 75 .map(AnimationInput::animatable) 76 .concatMap(backingState::animate) 77 .subscribe(mutationRelay::accept) 78 .addTo(disposable) 79 80 val completions: Observable<AnimationOutput> = Observables.combineLatest( 81 mutationRelay.filterIsInstance<Mutation.AnimationComplete>(), 82 cursorObservable, 83 playsObservable 84 ).replayingShare() 85 86 // When an animation completes, and there are more items to animate, start the next one 87 completions 88 .filter(AnimationOutput::canStartNext) 89 .distinctUntilChanged(AnimationOutput::key) 90 .map(AnimationOutput::nextCursor) 91 .subscribe(actionRelay::accept) 92 .addTo(disposable) 93 94 // When an animation completes, and there are no more items to animate, show the latest state 95 completions 96 .filter(AnimationOutput::showLatestState) 97 .distinctUntilChanged(AnimationOutput::key) 98 .subscribe { controller.currentEvent = null } // Show latest state 99 .addTo(disposable) 100 101 // Start when plays are available and court has been measured 102 Observables.combineLatest( 103 playsObservable, 104 backingState 105 ) 106 .firstOrError() 107 .map(StartPayload::seed) 108 .subscribe(actionRelay::accept) 109 .addTo(disposable) 110 111 actionRelay.filterIsInstance<Action.ViewDestroyed>() 112 .subscribe { mutationRelay.accept(Mutation.ClearBitmaps) } 113 .addTo(disposable) 114 } 115 116 fun accept(input: Action) = actionRelay.accept(input) 117 118 override fun onCleared() = disposable.clear() 119 120 private fun preAnimate(input: AnimationInput): Single<AnimationInput> { 121 if (input.currentItem.event != null) controller.currentEvent = input.currentItem.event 122 mutationRelay.accept(Mutation.SetBanner(input.banner)) 123 mutationRelay.accept(Mutation.TranslationY(0f, Target.Ball.A)) 124 mutationRelay.accept(Mutation.TranslationY(0f, Target.Ball.B)) 125 mutationRelay.accept(Mutation.SetMetaData( 126 isFreeThrow = input.currentItem.isFreeThrow, 127 ownTeamInPossession = input.currentItem.ownTeamInPossession 128 )) 129 return when (val playCode = (input.currentItem as? ScoringItem.PlayByPlayEntry)?.playCode) { 130 null -> Single.just(input) 131 else -> resources.cacheImages(input.state, playCode.ballDrawableResources).andThen(Single.just(input)) 132 } 133 } 134 135 private fun Resources.cacheImages(state: State, drawableResources: List<Int>): Completable = 136 Completable.merge(drawableResources.map { id -> 137 if (state.ballBitmapCache[id] != null) Completable.complete() 138 else loadBitMap(id, state.bitmapDownSampleFactor) 139 .doOnSuccess { bitmap -> 140 mutationRelay.accept(Mutation.UpdateBitmapCache(id to bitmap)) 141 }.ignoreElement() 142 }) 143} 144 145private fun Resources.state(dimensions: Action.Dimensions) = Single.fromCallable { 146 // Original court image is 5000 * 2238; the display height is larger so we'll use that instead of the width 147 val bitmapDownSampleFactor = max(2, (5000 / dimensions.displaySize.height).toInt()) 148 val courtBitmap = downSampleBitmap(this, R.drawable.bg_hoops_court, bitmapDownSampleFactor) 149 val hoopBitmap = downSampleBitmap(this, R.drawable.bg_hoops_ft, bitmapDownSampleFactor) 150 State( 151 dimensions = dimensions, 152 bitmapDownSampleFactor = bitmapDownSampleFactor, 153 courtBitmap = courtBitmap, 154 hoopBitmap = hoopBitmap 155 ) 156} 157 .subscribeOn(Schedulers.io()) 158 .observeOn(AndroidSchedulers.mainThread()) 159 .cache() 160 161private fun Resources.loadBitMap(drawableRes: Int, bitmapDownSampleFactor: Int) = Single.fromCallable { 162 downSampleBitmap(this, drawableRes, bitmapDownSampleFactor) 163} 164 165sealed class Animatable { 166 abstract val isNested: Boolean 167 168 /** 169 * Used to identify the [ScoringItem] being animated 170 */ 171 abstract val itemId: String 172 173 /** 174 * Pans the court from one end to the other 175 */ 176 data class CourtPan( 177 override val isNested: Boolean = false, 178 override val itemId: String = "", 179 val position: Position 180 ) : Animatable() { 181 sealed class Position { 182 object Left : Position() 183 object Right : Position() 184 object Opposite : Position() 185 object Unchanged : Position() 186 } 187 } 188 189 /** 190 * Fades an item in or out 191 */ 192 data class Alpha( 193 override val isNested: Boolean = false, 194 override val itemId: String = "", 195 val springStiffness: Float = SpringForce.STIFFNESS_VERY_LOW, 196 val target: Target, 197 val opacity: Float = 1.0f 198 ) : Animatable() 199 200 /** 201 * Moves an item up or down 202 */ 203 data class VerticalTranslation( 204 override val isNested: Boolean = false, 205 override val itemId: String = "", 206 val springStiffness: Float = SpringForce.STIFFNESS_LOW, 207 val target: Target, 208 val atNaturalPosition: Boolean 209 ) : Animatable() 210 211 /** 212 * Sets a ball image into a target 213 */ 214 data class BallImage( 215 override val isNested: Boolean = false, 216 override val itemId: String = "", 217 val imageResource: Int?, 218 val target: Target.Ball 219 ) : Animatable() 220 221 /** 222 * Runs the [nested] animations consecutively 223 */ 224 data class Series( 225 override val isNested: Boolean = false, 226 override val itemId: String = "", 227 val nested: List<Animatable> 228 ) : Animatable() 229 230 /** 231 * Runs the [nested] animations simultaneously 232 */ 233 data class Parallel( 234 override val isNested: Boolean = false, 235 override val itemId: String = "", 236 val nested: List<Animatable> 237 ) : Animatable() 238 239 /** 240 * Delays for [millis] milliseconds 241 */ 242 data class Delay( 243 override val isNested: Boolean = false, 244 override val itemId: String = "", 245 val millis: Long 246 ) : Animatable() 247 248 /** 249 * Does not animate at all 250 */ 251 data class Drop( 252 override val isNested: Boolean = false, 253 override val itemId: String = "" 254 ) : Animatable() 255} 256 257/** 258 * Creates a [Mutation] stream as the result of running the [animatable] on the [State] 259 * and notifies of the completion. 260 */ 261fun Observable<State>.animate(animatable: Animatable): Observable<Mutation> = when (animatable) { 262 is Animatable.CourtPan -> springObservable( 263 stiffness = SpringForce.STIFFNESS_VERY_LOW, 264 finalPosition = { it.courtTranslationX(animatable.position) }, 265 getter = State::courtTranslationX, 266 setter = { Mutation.CourtPan(x = it) }, 267 // Do not animate if the court it not visible, just snap into place, 268 // used for if there was a free throw background showing prior 269 shortCircuit = { currentState: State -> currentState.previousMetaData.isFreeThrow } 270 ) 271 is Animatable.Alpha -> springObservable( 272 stiffness = animatable.springStiffness, 273 finalPosition = { 100f * animatable.opacity }, 274 getter = { it.targetAlphaMap.getValue(animatable.target) * 100 }, 275 setter = { Mutation.Alpha(alpha = it / 100, target = animatable.target) } 276 ) 277 is Animatable.VerticalTranslation -> springObservable( 278 stiffness = animatable.springStiffness, 279 finalPosition = { 280 if (animatable.atNaturalPosition) 0f else when (val target = animatable.target) { 281 is Target.Banner.Large -> it.dimensions.largeBannerOffscreenTranslationY 282 is Target.Banner.Small -> it.dimensions.smallBannerOffScreenTranslationY 283 is Target.Ball.A -> it.ballDropTranslationY(target) 284 is Target.Ball.B -> it.ballDropTranslationY(target) 285 is Target.Background.Court, Target.Background.Hoop -> 0f 286 } 287 }, 288 getter = { it.targetTranslationYMap.getValue(animatable.target) }, 289 setter = { Mutation.TranslationY(translation = it, target = animatable.target) } 290 ) 291 is Animatable.BallImage -> ballImageObservable(animatable.imageResource, animatable.target) 292 is Animatable.Series -> Observable.concat(animatable.nested.map(Animatable::asNested).map(this::animate)) 293 is Animatable.Parallel -> Observable.merge(animatable.nested.map(Animatable::asNested).map(this::animate)) 294 is Animatable.Delay -> Observable.timer(animatable.millis, TimeUnit.MILLISECONDS, delayScheduler).flatMap { Observable.empty() } 295 is Animatable.Drop -> Observable.empty() 296}.concatWith(if (animatable.isNested) Observable.empty() else Observable.just(Mutation.AnimationComplete(animatable.itemId)))
7