diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 7f82a739..c67bcba0 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -1,5 +1,6 @@ import Versions.androidXCoreVersion import Versions.archVersion +import Versions.blendVersion import Versions.butterKnifeVersion import Versions.constraintLayoutVersion import Versions.coroutinesVersion @@ -46,6 +47,8 @@ object Dependencies { const val androidXCore = "androidx.core:core-ktx:$androidXCoreVersion" const val material = "com.google.android.material:material:$materialVersion" + const val blend = "com.wealthfront:blend-library:$blendVersion" + const val blendTest = "com.wealthfront:blend-test:$blendVersion" const val junit = "junit:junit:$junitVersion" const val junitTestExt = "androidx.test.ext:junit-ktx:$junitTestExtVersion" const val truth = "com.google.truth:truth:$truthVersion" diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 38d9e3a5..f9a01e52 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,6 +1,6 @@ object Versions { const val compileSdkVersion = 30 - const val minSdkVersion = 18 + const val minSdkVersion = 21 const val targetSdkVersion = 30 const val kotlinVersion = "1.5.20" @@ -26,6 +26,7 @@ object Versions { const val okhttpVersion = "4.4.0" const val javaInjectVersion = "1" const val materialVersion = "1.4.0" + const val blendVersion = "0.2.2" const val coroutinesVersion = "1.4.3" const val testCoreVersion = "1.4.0" diff --git a/magellan-library/build.gradle b/magellan-library/build.gradle index aeb01f04..ac901ae6 100644 --- a/magellan-library/build.gradle +++ b/magellan-library/build.gradle @@ -56,6 +56,7 @@ dependencies { implementation Dependencies.inject implementation Dependencies.coroutines implementation Dependencies.coroutinesAndroid + api Dependencies.blend testImplementation project(':internal-test-support') testImplementation Dependencies.testCore @@ -67,6 +68,7 @@ dependencies { testImplementation Dependencies.archTesting testImplementation Dependencies.robolectric testImplementation Dependencies.coroutinesTest + testImplementation Dependencies.blendTest // Bug in AGP: Follow this issue - https://issuetracker.google.com/issues/141840950 // lintPublish project(':magellan-lint') diff --git a/magellan-library/src/main/java/com/wealthfront/magellan/transitions/DefaultTransition.kt b/magellan-library/src/main/java/com/wealthfront/magellan/transitions/DefaultTransition.kt index 86ce2d86..1293b81a 100644 --- a/magellan-library/src/main/java/com/wealthfront/magellan/transitions/DefaultTransition.kt +++ b/magellan-library/src/main/java/com/wealthfront/magellan/transitions/DefaultTransition.kt @@ -1,22 +1,28 @@ package com.wealthfront.magellan.transitions -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.AnimatorSet -import android.animation.ObjectAnimator -import android.util.Property import android.view.View -import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import android.view.ViewGroup +import com.wealthfront.blend.Blend +import com.wealthfront.blend.dsl.AnimatorBuilder +import com.wealthfront.blend.dsl.fadeIn +import com.wealthfront.blend.dsl.fadeOut +import com.wealthfront.blend.dsl.scale import com.wealthfront.magellan.Direction import com.wealthfront.magellan.navigation.NavigationEvent +private const val ENTER_TRANSITION_LENGTH_MILLIS = 300L +private const val EXIT_TRANSITION_LENGTH_MILLIS = 250L +private const val SCALE_UP_FACTOR = 1.15f +private const val SCALE_DOWN_FACTOR = 0.85f + /** * The default transition for all [NavigationEvent]s where another [MagellanTransition] isn't - * defined. Performs a right-to-left slide on entrance and a left-to-right slide on exit. Uses a - * [FastOutSlowInInterpolator] for both per + * defined. Performs a fade and zoom (similar to Android 12 settings) on entrance and exit. Uses + * [AnimatorBuilder.emphasizeEase] for both per * [the Material Design guidelines](https://material.io/design/motion/speed.html#easing). + * The exit animation is also slightly shorter than the entrance, per the guidelines. */ -public class DefaultTransition : MagellanTransition { +public class DefaultTransition(private val blend: Blend = Blend()) : MagellanTransition { override fun animate( from: View?, @@ -24,30 +30,63 @@ public class DefaultTransition : MagellanTransition { direction: Direction, onAnimationEndCallback: () -> Unit ) { - val animator = createAnimator(from, to, direction) - animator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - onAnimationEndCallback() + when (direction) { + Direction.FORWARD -> animateForward(from, to, onAnimationEndCallback) + Direction.BACKWARD -> animateBackward(from, to, onAnimationEndCallback) + }.let { } + } + + private fun animateForward(from: View?, to: View, onAnimationEndCallback: () -> Unit) { + blend { + immediate() + target(to).animations { + fadeOut() + scale(SCALE_DOWN_FACTOR) + } + doOnStart { + // Put `to` behind `from` + val parent = to.parent as ViewGroup + parent.removeView(to) + parent.addView(to, 0) + } + }.then { + emphasizeEase() + duration(ENTER_TRANSITION_LENGTH_MILLIS) + from?.let { fromView -> + target(fromView).animations { + fadeOut() + scale(SCALE_UP_FACTOR) + } + } + target(to).animations { + fadeIn() + scale(1f) } - }) - animator.start() + doOnFinishedEvenIfInterrupted(onAnimationEndCallback) + }.start() } - private fun createAnimator( - from: View?, - to: View, - direction: Direction - ): AnimatorSet { - val sign = direction.sign() - val axis: Property = View.TRANSLATION_X - val toTranslation = sign * to.width - val set = AnimatorSet() - if (from != null) { - val fromTranslation = sign * -from.width - set.play(ObjectAnimator.ofFloat(from, axis, 0f, fromTranslation.toFloat())) - } - set.play(ObjectAnimator.ofFloat(to, axis, toTranslation.toFloat(), 0f)) - set.interpolator = FastOutSlowInInterpolator() - return set + private fun animateBackward(from: View?, to: View, onAnimationEndCallback: () -> Unit) { + blend { + immediate() + target(to).animations { + fadeOut() + scale(SCALE_UP_FACTOR) + } + }.then { + emphasizeEase() + duration(EXIT_TRANSITION_LENGTH_MILLIS) + from?.let { fromView -> + target(fromView).animations { + fadeOut() + scale(SCALE_DOWN_FACTOR) + } + } + target(to).animations { + fadeIn() + scale(1f) + } + doOnFinishedEvenIfInterrupted(onAnimationEndCallback) + }.start() } } diff --git a/magellan-library/src/main/java/com/wealthfront/magellan/transitions/ShowTransition.kt b/magellan-library/src/main/java/com/wealthfront/magellan/transitions/ShowTransition.kt index 2422c1f3..e9f38cf2 100644 --- a/magellan-library/src/main/java/com/wealthfront/magellan/transitions/ShowTransition.kt +++ b/magellan-library/src/main/java/com/wealthfront/magellan/transitions/ShowTransition.kt @@ -1,22 +1,29 @@ package com.wealthfront.magellan.transitions -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.AnimatorSet -import android.animation.ObjectAnimator -import android.util.Property import android.view.View -import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import android.view.ViewGroup +import com.wealthfront.blend.Blend +import com.wealthfront.blend.dsl.AnimatorBuilder +import com.wealthfront.blend.dsl.fadeIn +import com.wealthfront.blend.dsl.fadeOut +import com.wealthfront.blend.dsl.scale +import com.wealthfront.blend.dsl.translationY import com.wealthfront.magellan.Direction import com.wealthfront.magellan.Direction.BACKWARD import com.wealthfront.magellan.Direction.FORWARD +private const val ENTER_TRANSITION_LENGTH_MILLIS = 300L +private const val EXIT_TRANSITION_LENGTH_MILLIS = 250L +private const val SCALE_DOWN_FACTOR = 0.85f +private const val HEIGHT_OFFSET_FACTOR = 0.2f + /** - * A vertical version of [DefaultTransition]. Performs a bottom-to-top slide on entrance and a - * top-to-bottom slide on exit. Uses a [FastOutSlowInInterpolator] for both per + * A vertical version of [DefaultTransition]. Performs an upward slide and fade in on entrance and a + * downward slide and fade out on exit. Uses [AnimatorBuilder.emphasizeEase] for both per * [the Material Design guidelines](https://material.io/design/motion/speed.html#easing). + * The exit animation is also slightly shorter than the entrance, per the guidelines. */ -public class ShowTransition : MagellanTransition { +public class ShowTransition(private val blend: Blend = Blend()) : MagellanTransition { override fun animate( from: View?, @@ -24,29 +31,63 @@ public class ShowTransition : MagellanTransition { direction: Direction, onAnimationEndCallback: () -> Unit ) { - val animator = createAnimator(from, to, direction) - animator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - onAnimationEndCallback() + when (direction) { + FORWARD -> animateForward(from, to, onAnimationEndCallback) + BACKWARD -> animateBackward(from, to, onAnimationEndCallback) + }.let { } + } + + private fun animateForward(from: View?, to: View, onAnimationEndCallback: () -> Unit) { + blend { + immediate() + target(to).animations { + fadeOut() + translationY(to.height * HEIGHT_OFFSET_FACTOR) } - }) - animator.start() + }.then { + emphasizeEase() + duration(ENTER_TRANSITION_LENGTH_MILLIS) + from?.let { fromView -> + target(fromView).animations { + fadeOut() + scale(SCALE_DOWN_FACTOR) + } + } + target(to).animations { + fadeIn() + translationY(0f) + } + doOnFinishedEvenIfInterrupted(onAnimationEndCallback) + }.start() } - private fun createAnimator( - from: View?, - to: View, - direction: Direction - ): AnimatorSet { - val axis: Property = View.TRANSLATION_Y - val fromTranslation: Int = if (direction == FORWARD) 0 else from!!.height - val toTranslation: Int = if (direction == BACKWARD) 0 else to.height - val set = AnimatorSet() - if (from != null) { - set.play(ObjectAnimator.ofFloat(from, axis, 0f, fromTranslation.toFloat())) - } - set.play(ObjectAnimator.ofFloat(to, axis, toTranslation.toFloat(), 0f)) - set.interpolator = FastOutSlowInInterpolator() - return set + private fun animateBackward(from: View?, to: View, onAnimationEndCallback: () -> Unit) { + blend { + immediate() + target(to).animations { + fadeOut() + scale(SCALE_DOWN_FACTOR) + } + doOnStart { + // Put `to` behind `from` + val parent = to.parent as ViewGroup + parent.removeView(to) + parent.addView(to, 0) + } + }.then { + emphasizeEase() + duration(EXIT_TRANSITION_LENGTH_MILLIS) + from?.let { fromView -> + target(fromView).animations { + fadeOut() + translationY(fromView.height * HEIGHT_OFFSET_FACTOR) + } + } + target(to).animations { + fadeIn() + scale(1f) + } + doOnFinishedEvenIfInterrupted(onAnimationEndCallback) + }.start() } } diff --git a/magellan-library/src/test/java/com/wealthfront/magellan/transitions/DefaultTransitionTest.java b/magellan-library/src/test/java/com/wealthfront/magellan/transitions/DefaultTransitionTest.java deleted file mode 100644 index 5c0d3c2f..00000000 --- a/magellan-library/src/test/java/com/wealthfront/magellan/transitions/DefaultTransitionTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.wealthfront.magellan.transitions; - -import android.view.View; - -import com.wealthfront.magellan.Direction; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.LooperMode; - -import kotlin.Unit; - -import static androidx.test.core.app.ApplicationProvider.getApplicationContext; -import static com.google.common.truth.Truth.assertThat; -import static com.wealthfront.magellan.Direction.BACKWARD; -import static com.wealthfront.magellan.Direction.FORWARD; -import static org.robolectric.Robolectric.flushForegroundThreadScheduler; -import static org.robolectric.Robolectric.getForegroundThreadScheduler; - -@RunWith(RobolectricTestRunner.class) -@LooperMode(LooperMode.Mode.LEGACY) -public class DefaultTransitionTest { - - private boolean onAnimationEndCalled; - - @Before - public void setUp() { - onAnimationEndCalled = false; - getForegroundThreadScheduler().pause(); - } - - @Test - public void animateGoTo() throws Exception { - checkAnimate(FORWARD); - } - - @Test - public void animateGoBack() throws Exception { - checkAnimate(BACKWARD); - } - - private void checkAnimate(Direction direction) { - new DefaultTransition().animate(new View(getApplicationContext()), - new View(getApplicationContext()), direction, () -> { - onAnimationEndCalled = true; - return Unit.INSTANCE; - }); - flushForegroundThreadScheduler(); - assertThat(onAnimationEndCalled).isTrue(); - } - -} \ No newline at end of file diff --git a/magellan-library/src/test/java/com/wealthfront/magellan/transitions/DefaultTransitionTest.kt b/magellan-library/src/test/java/com/wealthfront/magellan/transitions/DefaultTransitionTest.kt new file mode 100644 index 00000000..9d182dd2 --- /dev/null +++ b/magellan-library/src/test/java/com/wealthfront/magellan/transitions/DefaultTransitionTest.kt @@ -0,0 +1,69 @@ +package com.wealthfront.magellan.transitions + +import android.view.View +import android.widget.FrameLayout +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import com.google.common.truth.Truth.assertThat +import com.wealthfront.blend.mock.ImmediateBlend +import com.wealthfront.magellan.Direction.BACKWARD +import com.wealthfront.magellan.Direction.FORWARD +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class DefaultTransitionTest { + + private lateinit var transition: DefaultTransition + private val blend = ImmediateBlend() + private var onAnimationEndCalled = false + + @Before + fun setUp() { + transition = DefaultTransition(blend) + onAnimationEndCalled = false + } + + @Test + fun animateGoTo() { + val parent = FrameLayout(getApplicationContext()) + val from = View(getApplicationContext()) + val to = View(getApplicationContext()) + parent.addView(from) + parent.addView(to) + + transition.animate( + from = from, + to = to, + direction = FORWARD, + onAnimationEndCallback = { onAnimationEndCalled = true } + ) + + assertThat(to.scaleX).isWithin(0.01f).of(1f) + assertThat(to.scaleY).isWithin(0.01f).of(1f) + assertThat(to.alpha).isWithin(0.01f).of(1f) + assertThat(onAnimationEndCalled).isTrue() + } + + @Test + fun animateGoBack() { + val parent = FrameLayout(getApplicationContext()) + val from = View(getApplicationContext()) + val to = View(getApplicationContext()) + parent.addView(from) + parent.addView(to) + + transition.animate( + from = from, + to = to, + direction = BACKWARD, + onAnimationEndCallback = { onAnimationEndCalled = true } + ) + + assertThat(to.scaleX).isWithin(0.01f).of(1f) + assertThat(to.scaleY).isWithin(0.01f).of(1f) + assertThat(to.alpha).isWithin(0.01f).of(1f) + assertThat(onAnimationEndCalled).isTrue() + } +} diff --git a/magellan-library/src/test/java/com/wealthfront/magellan/transitions/ShowTransitionTest.java b/magellan-library/src/test/java/com/wealthfront/magellan/transitions/ShowTransitionTest.java deleted file mode 100644 index 22e4d106..00000000 --- a/magellan-library/src/test/java/com/wealthfront/magellan/transitions/ShowTransitionTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.wealthfront.magellan.transitions; - -import android.view.View; - -import com.wealthfront.magellan.Direction; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.LooperMode; - -import kotlin.Unit; - -import static androidx.test.core.app.ApplicationProvider.getApplicationContext; -import static com.google.common.truth.Truth.assertThat; -import static com.wealthfront.magellan.Direction.BACKWARD; -import static com.wealthfront.magellan.Direction.FORWARD; -import static org.robolectric.Robolectric.flushForegroundThreadScheduler; -import static org.robolectric.Robolectric.getForegroundThreadScheduler; - -@RunWith(RobolectricTestRunner.class) -@LooperMode(LooperMode.Mode.LEGACY) -public class ShowTransitionTest { - - private boolean onAnimationEndCalled; - - @Before - public void setUp() { - onAnimationEndCalled = false; - getForegroundThreadScheduler().pause(); - } - - @Test - public void animateGoTo() throws Exception { - checkAnimate(FORWARD); - } - - @Test - public void animateGoBack() throws Exception { - checkAnimate(BACKWARD); - } - - private void checkAnimate(Direction direction) { - new ShowTransition().animate(new View(getApplicationContext()), new View(getApplicationContext()), direction, - () -> { - onAnimationEndCalled = true; - return Unit.INSTANCE; - }); - flushForegroundThreadScheduler(); - assertThat(onAnimationEndCalled).isTrue(); - } - -} \ No newline at end of file diff --git a/magellan-library/src/test/java/com/wealthfront/magellan/transitions/ShowTransitionTest.kt b/magellan-library/src/test/java/com/wealthfront/magellan/transitions/ShowTransitionTest.kt new file mode 100644 index 00000000..8407753d --- /dev/null +++ b/magellan-library/src/test/java/com/wealthfront/magellan/transitions/ShowTransitionTest.kt @@ -0,0 +1,70 @@ +package com.wealthfront.magellan.transitions + +import android.view.View +import android.widget.FrameLayout +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth +import com.wealthfront.blend.mock.ImmediateBlend +import com.wealthfront.magellan.Direction +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ShowTransitionTest { + + private lateinit var transition: ShowTransition + private val blend = ImmediateBlend() + private var onAnimationEndCalled = false + + @Before + fun setUp() { + transition = ShowTransition(blend) + onAnimationEndCalled = false + } + + @Test + fun animateGoTo() { + val parent = FrameLayout(ApplicationProvider.getApplicationContext()) + val from = View(ApplicationProvider.getApplicationContext()) + val to = View(ApplicationProvider.getApplicationContext()) + parent.addView(from) + parent.addView(to) + + transition.animate( + from = from, + to = to, + direction = Direction.FORWARD, + onAnimationEndCallback = { onAnimationEndCalled = true } + ) + + Truth.assertThat(to.scaleX).isWithin(0.01f).of(1f) + Truth.assertThat(to.scaleY).isWithin(0.01f).of(1f) + Truth.assertThat(to.translationY).isWithin(0.01f).of(0f) + Truth.assertThat(to.alpha).isWithin(0.01f).of(1f) + Truth.assertThat(onAnimationEndCalled).isTrue() + } + + @Test + fun animateGoBack() { + val parent = FrameLayout(ApplicationProvider.getApplicationContext()) + val from = View(ApplicationProvider.getApplicationContext()) + val to = View(ApplicationProvider.getApplicationContext()) + parent.addView(from) + parent.addView(to) + + transition.animate( + from = from, + to = to, + direction = Direction.BACKWARD, + onAnimationEndCallback = { onAnimationEndCalled = true } + ) + + Truth.assertThat(to.scaleX).isWithin(0.01f).of(1f) + Truth.assertThat(to.scaleY).isWithin(0.01f).of(1f) + Truth.assertThat(to.translationY).isWithin(0.01f).of(0f) + Truth.assertThat(to.alpha).isWithin(0.01f).of(1f) + Truth.assertThat(onAnimationEndCalled).isTrue() + } +}