From f125ab01ee07d7144f93e1c67cfcf425f0df5779 Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Fri, 17 Sep 2021 04:37:17 +0700 Subject: [PATCH] Change how the bottom navigation is hidden (#5823) * Change how the bottom navigation is hidden Modifies the translationY instead of the height. * Cleanups --- .../tachiyomi/ui/library/LibraryController.kt | 4 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 46 +---- .../tachiyomi/ui/main/ViewHeightAnimator.kt | 107 ----------- .../ui/recent/updates/UpdatesController.kt | 4 +- .../util/system/AnimationExtensions.kt | 6 + .../widget/TachiyomiBottomNavigationView.kt | 174 ++++++++++++++++++ app/src/main/res/layout/main_activity.xml | 2 +- 7 files changed, 193 insertions(+), 150 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/main/ViewHeightAnimator.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiBottomNavigationView.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index d45c60585..1e8996830 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -383,7 +383,7 @@ class LibraryController( actionMode!!, R.menu.library_selection ) { onActionItemClicked(it!!) } - (activity as? MainActivity)?.showBottomNav(visible = false, expand = true) + (activity as? MainActivity)?.showBottomNav(false) } } @@ -492,7 +492,7 @@ class LibraryController( selectionRelay.call(LibrarySelectionEvent.Cleared()) binding.actionToolbar.hide() - (activity as? MainActivity)?.showBottomNav(visible = true, expand = true) + (activity as? MainActivity)?.showBottomNav(true) actionMode = null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index e261520e6..d1e9d69aa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -10,7 +10,6 @@ import android.view.Gravity import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.view.ActionMode -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.animation.doOnEnd import androidx.core.splashscreen.SplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen @@ -64,7 +63,6 @@ import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.isTablet import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.setNavigationBarTransparentCompat -import eu.kanade.tachiyomi.widget.HideBottomNavigationOnScrollBehavior import kotlinx.coroutines.delay import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn @@ -86,8 +84,6 @@ class MainActivity : BaseViewBindingActivity() { } } - private var bottomNavAnimator: ViewHeightAnimator? = null - private var isConfirmingExit: Boolean = false private var isHandlingShortcut: Boolean = false @@ -138,15 +134,6 @@ class MainActivity : BaseViewBindingActivity() { } setSplashScreenExitAnimation(splashScreen) - if (binding.bottomNav != null) { - bottomNavAnimator = ViewHeightAnimator(binding.bottomNav!!) - - // Set behavior of bottom nav - preferences.hideBottomBarOnScroll() - .asImmediateFlow { setBottomNavBehaviorOnScroll() } - .launchIn(lifecycleScope) - } - if (binding.sideNav != null) { preferences.sideNavIconAlignment() .asImmediateFlow { @@ -532,11 +519,11 @@ class MainActivity : BaseViewBindingActivity() { binding.appbar.setExpanded(true) if ((from == null || from is RootController) && to !is RootController) { - showNav(visible = false, expand = true) + showNav(false) } if (to is RootController) { // Always show bottom nav again when returning to a RootController - showNav(visible = true, expand = from !is RootController) + showNav(true) } if (from is TabbedController) { @@ -587,27 +574,22 @@ class MainActivity : BaseViewBindingActivity() { } } - private fun showNav(visible: Boolean, expand: Boolean = false) { - showBottomNav(visible, expand) + private fun showNav(visible: Boolean) { + showBottomNav(visible) showSideNav(visible) } // Also used from some controllers to swap bottom nav with action toolbar - fun showBottomNav(visible: Boolean, expand: Boolean = false) { + fun showBottomNav(visible: Boolean) { if (visible) { - binding.bottomNav?.translationY = 0F - if (expand) { - bottomNavAnimator?.expand() - } + binding.bottomNav?.slideUp() } else { - bottomNavAnimator?.collapse() + binding.bottomNav?.slideDown() } } private fun showSideNav(visible: Boolean) { - binding.sideNav?.let { - it.isVisible = visible - } + binding.sideNav?.isVisible = visible } /** @@ -622,18 +604,6 @@ class MainActivity : BaseViewBindingActivity() { } } - private fun setBottomNavBehaviorOnScroll() { - showNav(visible = true) - - binding.bottomNav?.updateLayoutParams { - behavior = when { - preferences.hideBottomBarOnScroll().get() -> HideBottomNavigationOnScrollBehavior() - else -> null - } - } - binding.bottomNav?.translationY = 0F - } - private val nav: NavigationBarView get() = binding.bottomNav ?: binding.sideNav!! diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ViewHeightAnimator.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ViewHeightAnimator.kt deleted file mode 100644 index ce22a9219..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ViewHeightAnimator.kt +++ /dev/null @@ -1,107 +0,0 @@ -package eu.kanade.tachiyomi.ui.main - -import android.animation.ObjectAnimator -import android.view.View -import android.view.ViewTreeObserver -import android.view.animation.DecelerateInterpolator -import androidx.annotation.Keep - -class ViewHeightAnimator(val view: View, val duration: Long = 250L) { - - /** - * The default height of the view. It's unknown until the view is layout. - */ - private var height = 0 - - /** - * Whether the last state of the view is shown or hidden. - */ - private var isLastStateShown = true - - /** - * Animation used to expand and collapse the view. - */ - private val animation by lazy { - ObjectAnimator.ofInt(this, "height", height).apply { - duration = this@ViewHeightAnimator.duration - interpolator = DecelerateInterpolator() - } - } - - init { - view.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - if (view.height > 0) { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - - // Save the tabs default height. - height = view.height - - // Now that we know the height, set the initial height. - if (isLastStateShown) { - setHeight(height) - } else { - setHeight(0) - } - } - } - } - ) - } - - /** - * Sets the height of the tab layout. - * - * @param newHeight The new height of the tab layout. - */ - @Keep - fun setHeight(newHeight: Int) { - view.layoutParams.height = newHeight - view.requestLayout() - } - - /** - * Returns the height of the tab layout. This method is also called from the animator through - * reflection. - */ - fun getHeight(): Int { - return view.layoutParams.height - } - - /** - * Expands the tab layout with an animation. - */ - fun expand() { - if (isMeasured) { - if (getHeight() != height) { - animation.setIntValues(height) - animation.start() - } else { - animation.cancel() - } - } - isLastStateShown = true - } - - /** - * Collapse the tab layout with an animation. - */ - fun collapse() { - if (isMeasured) { - if (getHeight() != 0) { - animation.setIntValues(0) - animation.start() - } else { - animation.cancel() - } - } - isLastStateShown = false - } - - /** - * Returns whether the tab layout has a known height. - */ - private val isMeasured: Boolean - get() = height > 0 -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt index 620e10e72..d51f2de03 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt @@ -180,7 +180,7 @@ class UpdatesController : actionMode!!, R.menu.updates_chapter_selection ) { onActionItemClicked(it!!) } - (activity as? MainActivity)?.showBottomNav(visible = false, expand = true) + (activity as? MainActivity)?.showBottomNav(false) } toggleSelection(position) @@ -386,7 +386,7 @@ class UpdatesController : adapter?.clearSelection() binding.actionToolbar.hide() - (activity as? MainActivity)?.showBottomNav(visible = true, expand = true) + (activity as? MainActivity)?.showBottomNav(true) actionMode = null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/AnimationExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/AnimationExtensions.kt index 1946c1179..3136e38c5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/AnimationExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/AnimationExtensions.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.util.system import android.content.Context +import android.view.ViewPropertyAnimator import android.view.animation.Animation import androidx.constraintlayout.motion.widget.MotionScene.Transition @@ -14,3 +15,8 @@ fun Transition.applySystemAnimatorScale(context: Context) { // End layout of cover expanding animation tends to break when the transition is less than ~25ms this.duration = (this.duration * context.animatorDurationScale).toInt().coerceAtLeast(25) } + +/** Scale the duration of this [ViewPropertyAnimator] by [Context.animatorDurationScale] */ +fun ViewPropertyAnimator.applySystemAnimatorScale(context: Context): ViewPropertyAnimator = apply { + this.duration = (this.duration * context.animatorDurationScale).toLong() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiBottomNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiBottomNavigationView.kt new file mode 100644 index 000000000..238ac1a58 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiBottomNavigationView.kt @@ -0,0 +1,174 @@ +package eu.kanade.tachiyomi.widget + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.TimeInterpolator +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.ViewPropertyAnimator +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.doOnLayout +import androidx.core.view.doOnNextLayout +import androidx.core.view.updateLayoutParams +import androidx.customview.view.AbsSavedState +import androidx.interpolator.view.animation.FastOutLinearInInterpolator +import androidx.interpolator.view.animation.LinearOutSlowInInterpolator +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.google.android.material.bottomnavigation.BottomNavigationView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.util.system.applySystemAnimatorScale +import kotlinx.coroutines.flow.launchIn +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class TachiyomiBottomNavigationView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.bottomNavigationStyle, + defStyleRes: Int = R.style.Widget_Design_BottomNavigationView +) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes) { + + private var currentAnimator: ViewPropertyAnimator? = null + + private var currentState = STATE_UP + + init { + // Hide on scroll + doOnLayout { + findViewTreeLifecycleOwner()?.lifecycleScope?.let { scope -> + Injekt.get().hideBottomBarOnScroll() + .asImmediateFlow { + updateLayoutParams { + behavior = if (it) { + HideBottomNavigationOnScrollBehavior() + } else { + null + } + } + } + .launchIn(scope) + } + } + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return SavedState(superState).also { + it.currentState = currentState + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + doOnNextLayout { + if (state.currentState == STATE_UP) { + slideUp(animate = false) + } else if (state.currentState == STATE_DOWN) { + slideDown(animate = false) + } + } + } else { + super.onRestoreInstanceState(state) + } + } + + override fun setTranslationY(translationY: Float) { + // Disallow translation change when state down + if (currentState == STATE_DOWN) return + super.setTranslationY(translationY) + } + + /** + * Shows this view up. + * + * @param animate True if slide up should be animated + */ + fun slideUp(animate: Boolean = true) { + currentAnimator?.cancel() + clearAnimation() + + currentState = STATE_UP + animateTranslation( + 0F, + if (animate) SLIDE_UP_ANIMATION_DURATION else 0, + LinearOutSlowInInterpolator() + ) + } + + /** + * Hides this view down. [setTranslationY] won't work until [slideUp] is called. + * + * @param animate True if slide down should be animated + */ + fun slideDown(animate: Boolean = true) { + currentAnimator?.cancel() + clearAnimation() + + currentState = STATE_DOWN + animateTranslation( + height.toFloat(), + if (animate) SLIDE_DOWN_ANIMATION_DURATION else 0, + FastOutLinearInInterpolator() + ) + } + + private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) { + currentAnimator = animate() + .translationY(targetY) + .setInterpolator(interpolator) + .setDuration(duration) + .applySystemAnimatorScale(context) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + currentAnimator = null + postInvalidate() + } + }) + } + + internal class SavedState : AbsSavedState { + var currentState = STATE_UP + + constructor(superState: Parcelable) : super(superState) + + constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) { + currentState = source.readByte().toInt() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeByte(currentState.toByte()) + } + + companion object { + @JvmField + val CREATOR: Parcelable.ClassLoaderCreator = object : Parcelable.ClassLoaderCreator { + override fun createFromParcel(source: Parcel, loader: ClassLoader): SavedState { + return SavedState(source, loader) + } + + override fun createFromParcel(source: Parcel): SavedState { + return SavedState(source, null) + } + + override fun newArray(size: Int): Array { + return newArray(size) + } + } + } + } + + companion object { + private const val STATE_DOWN = 1 + private const val STATE_UP = 2 + + private const val SLIDE_UP_ANIMATION_DURATION = 225L + private const val SLIDE_DOWN_ANIMATION_DURATION = 175L + } +} diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index 30814b92f..e38b2f0a4 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -75,7 +75,7 @@ android:id="@+id/fab_layout" layout="@layout/main_activity_fab" /> -