From 310d4e58bbdb336d212dbb368e8667f93b7964d2 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Sun, 3 Jul 2022 20:15:34 +0300 Subject: [PATCH] Set status bar foreground when app bar is hidden --- .../material/appbar/KotatsuAppBarLayout.kt | 151 +++++++++++++++++ .../HideBottomNavigationOnScrollBehavior.kt | 103 ++++++++++++ .../ui/widgets/KotatsuBottomNavigationView.kt | 156 ++++++++++++++++++ .../ui/FavouritesContainerFragment.kt | 11 -- .../kotatsu/list/ui/MangaListFragment.kt | 4 - .../kotatsu/tracker/ui/FeedFragment.kt | 3 - .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 18 +- .../main/res/layout/fragment_favourites.xml | 3 +- 8 files changed, 429 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/google/android/material/appbar/KotatsuAppBarLayout.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuBottomNavigationView.kt diff --git a/app/src/main/java/com/google/android/material/appbar/KotatsuAppBarLayout.kt b/app/src/main/java/com/google/android/material/appbar/KotatsuAppBarLayout.kt new file mode 100644 index 000000000..cb2e1968b --- /dev/null +++ b/app/src/main/java/com/google/android/material/appbar/KotatsuAppBarLayout.kt @@ -0,0 +1,151 @@ +package com.google.android.material.appbar + +import android.animation.AnimatorSet +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.annotation.FloatRange +import com.google.android.material.animation.AnimationUtils +import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener +import com.google.android.material.shape.MaterialShapeDrawable +import org.koitharu.kotatsu.R +import com.google.android.material.R as materialR + +/** + * [AppBarLayout] with our own lift state handler and custom title alpha. + * + * Inside this package to access some package-private methods. + */ +class KotatsuAppBarLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppBarLayout(context, attrs) { + + private var lifted = true + + private val toolbar by lazy { findViewById(R.id.toolbar) } + + @FloatRange(from = 0.0, to = 1.0) + var titleTextAlpha = 1F + set(value) { + field = value + titleTextView?.alpha = field + } + + private var titleTextView: TextView? = null + set(value) { + field = value + field?.alpha = titleTextAlpha + } + + private var animatorSet: AnimatorSet? = null + + private var statusBarForegroundAnimator: ValueAnimator? = null + private val offsetListener = OnOffsetChangedListener { appBarLayout, verticalOffset -> + // Show status bar foreground when offset + val foreground = (appBarLayout?.statusBarForeground as? MaterialShapeDrawable) ?: return@OnOffsetChangedListener + val start = foreground.alpha + val end = if (verticalOffset != 0) 255 else 0 + + statusBarForegroundAnimator?.cancel() + if (animatorSet?.isRunning == true) { + foreground.alpha = end + return@OnOffsetChangedListener + } + if (start != end) { + statusBarForegroundAnimator = ValueAnimator.ofInt(start, end).apply { + duration = resources.getInteger(materialR.integer.app_bar_elevation_anim_duration).toLong() + interpolator = AnimationUtils.LINEAR_INTERPOLATOR + addUpdateListener { + foreground.alpha = it.animatedValue as Int + } + start() + } + } + } + + var isTransparentWhenNotLifted = false + set(value) { + if (field != value) { + field = value + updateStates() + } + } + + override fun isLiftOnScroll(): Boolean = false + + override fun isLifted(): Boolean = lifted + + override fun setLifted(lifted: Boolean): Boolean { + return if (this.lifted != lifted) { + this.lifted = lifted + updateStates() + true + } else { + false + } + } + + override fun setLiftedState(lifted: Boolean, force: Boolean): Boolean = false + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + addOnOffsetChangedListener(offsetListener) + toolbar.background.alpha = 0 // Use app bar background + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + removeOnOffsetChangedListener(offsetListener) + } + + @SuppressLint("Recycle") + private fun updateStates() { + val animators = mutableListOf() + + val fromElevation = elevation + val toElevation = if (lifted) { + resources.getDimension(materialR.dimen.design_appbar_elevation) + } else { + 0F + } + if (fromElevation != toElevation) { + ValueAnimator.ofFloat(fromElevation, toElevation).apply { + addUpdateListener { + elevation = it.animatedValue as Float + (statusBarForeground as? MaterialShapeDrawable)?.elevation = it.animatedValue as Float + } + animators.add(this) + } + } + + val transparent = if (lifted) false else isTransparentWhenNotLifted + val fromAlpha = (background as? MaterialShapeDrawable)?.alpha ?: background.alpha + val toAlpha = if (transparent) 0 else 255 + if (fromAlpha != toAlpha) { + ValueAnimator.ofInt(fromAlpha, toAlpha).apply { + addUpdateListener { + val value = it.animatedValue as Int + background.alpha = value + } + animators.add(this) + } + } + + if (animators.isNotEmpty()) { + animatorSet?.cancel() + animatorSet = AnimatorSet().apply { + duration = resources.getInteger(materialR.integer.app_bar_elevation_anim_duration).toLong() + interpolator = AnimationUtils.LINEAR_INTERPOLATOR + playTogether(*animators.toTypedArray()) + start() + } + } + } + + init { + statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt new file mode 100644 index 000000000..0210478c7 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt @@ -0,0 +1,103 @@ +package org.koitharu.kotatsu.base.ui.widgets + +import android.animation.ValueAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import androidx.appcompat.widget.Toolbar +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.bottomnavigation.BottomNavigationView +import org.koitharu.kotatsu.utils.ext.animatorDurationScale +import org.koitharu.kotatsu.utils.ext.findChild +import kotlin.math.roundToLong + +class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor( + context: Context? = null, + attrs: AttributeSet? = null, +) : CoordinatorLayout.Behavior(context, attrs) { + + @ViewCompat.NestedScrollType + private var lastStartedType: Int = 0 + + private var offsetAnimator: ValueAnimator? = null + + private var dyRatio = 1F + + override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean { + return dependency is AppBarLayout + } + + override fun onDependentViewChanged( + parent: CoordinatorLayout, + child: BottomNavigationView, + dependency: View, + ): Boolean { + val toolbarSize = (dependency as ViewGroup).findChild()?.height ?: 0 + dyRatio = if (toolbarSize > 0) { + child.height.toFloat() / toolbarSize + } else { + 1F + } + return false + } + + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: BottomNavigationView, + directTargetChild: View, + target: View, + axes: Int, + type: Int, + ): Boolean { + if (axes != ViewCompat.SCROLL_AXIS_VERTICAL) { + return false + } + lastStartedType = type + offsetAnimator?.cancel() + return true + } + + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, + child: BottomNavigationView, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int, + ) { + super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) + child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat()) + } + + override fun onStopNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: BottomNavigationView, + target: View, + type: Int, + ) { + if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) { + animateBottomNavigationVisibility(child, child.translationY < child.height / 2) + } + } + + private fun animateBottomNavigationVisibility(child: BottomNavigationView, isVisible: Boolean) { + offsetAnimator?.cancel() + offsetAnimator = ValueAnimator().apply { + interpolator = DecelerateInterpolator() + duration = (150 * child.context.animatorDurationScale).roundToLong() + addUpdateListener { + child.translationY = it.animatedValue as Float + } + } + offsetAnimator?.setFloatValues( + child.translationY, + if (isVisible) 0F else child.height.toFloat(), + ) + offsetAnimator?.start() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuBottomNavigationView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuBottomNavigationView.kt new file mode 100644 index 000000000..7ee897e80 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuBottomNavigationView.kt @@ -0,0 +1,156 @@ +package org.koitharu.kotatsu.base.ui.widgets + +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.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 org.koitharu.kotatsu.utils.ext.applySystemAnimatorScale +import com.google.android.material.R as materialR + +class KotatsuBottomNavigationView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = materialR.attr.bottomNavigationStyle, + defStyleRes: Int = materialR.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 { + updateLayoutParams { + behavior = HideBottomNavigationOnScrollBehavior() + } + } + } + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return SavedState(superState).also { + it.currentState = currentState + it.translationY = translationY + } + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + super.setTranslationY(state.translationY) + currentState = state.currentState + } 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. + */ + fun slideUp() = post { + currentAnimator?.cancel() + clearAnimation() + + currentState = STATE_UP + animateTranslation( + 0F, + SLIDE_UP_ANIMATION_DURATION, + LinearOutSlowInInterpolator(), + ) + } + + /** + * Hides this view down. [setTranslationY] won't work until [slideUp] is called. + */ + fun slideDown() = post { + currentAnimator?.cancel() + clearAnimation() + + currentState = STATE_DOWN + animateTranslation( + height.toFloat(), + SLIDE_DOWN_ANIMATION_DURATION, + 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 + var translationY = 0F + + constructor(superState: Parcelable) : super(superState) + + constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) { + currentState = source.readInt() + translationY = source.readFloat() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeInt(currentState) + out.writeFloat(translationY) + } + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt index 22939d9d9..383ed6b74 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt @@ -81,22 +81,11 @@ class FavouritesContainerFragment : } override fun onWindowInsetsChanged(insets: Insets) { - val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top - binding.root.updatePadding( - top = headerHeight - insets.top - ) - binding.pager.updatePadding( - // 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active) - top = -headerHeight + resources.resolveDp(8) - ) binding.tabs.apply { updatePadding( left = insets.left, right = insets.right ) - updateLayoutParams { - topMargin = insets.top - } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 568bb6427..a85146a57 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -185,10 +185,6 @@ abstract class MangaListFragment : right = insets.right, ) if (activity is MainActivity) { - binding.recyclerView.updatePadding( - top = headerHeight, - bottom = insets.bottom, - ) binding.swipeRefreshLayout.setProgressViewOffset( true, headerHeight + resources.resolveDp(-72), diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt index 3bcc46ea7..34622c0ab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -76,12 +76,9 @@ class FeedFragment : } override fun onWindowInsetsChanged(insets: Insets) { - val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top binding.recyclerView.updatePadding( - top = headerHeight + paddingVertical, left = insets.left + paddingHorizontal, right = insets.right + paddingHorizontal, - bottom = insets.bottom + paddingVertical, ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index 535a627d5..c33c6cb6f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -9,14 +9,19 @@ import android.net.Network import android.net.NetworkRequest import android.net.Uri import android.os.Build +import android.provider.Settings +import android.view.ViewGroup +import android.view.ViewPropertyAnimator import android.view.Window +import android.view.animation.Animation import androidx.activity.result.ActivityResultLauncher +import androidx.constraintlayout.motion.widget.MotionScene import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.children import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import androidx.work.CoroutineWorker import com.google.android.material.elevation.ElevationOverlayProvider -import kotlin.coroutines.resume import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.delay @@ -111,4 +116,15 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float elevation, ) } +} + +val Context.animatorDurationScale: Float + get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) + +fun ViewPropertyAnimator.applySystemAnimatorScale(context: Context): ViewPropertyAnimator = apply { + this.duration = (this.duration * context.animatorDurationScale).toLong() +} + +inline fun ViewGroup.findChild(): T? { + return children.find { it is T } as? T } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_favourites.xml b/app/src/main/res/layout/fragment_favourites.xml index d93d500b3..b15643e7e 100644 --- a/app/src/main/res/layout/fragment_favourites.xml +++ b/app/src/main/res/layout/fragment_favourites.xml @@ -15,7 +15,8 @@ + android:layout_height="match_parent" + android:paddingVertical="8dp"/>