Set status bar foreground when app bar is hidden

This commit is contained in:
Zakhar Timoshenko
2022-07-03 20:15:34 +03:00
parent 455351e3a8
commit 310d4e58bb
8 changed files with 429 additions and 20 deletions

View File

@@ -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<MaterialToolbar>(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<ValueAnimator>()
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)
}
}

View File

@@ -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<BottomNavigationView>(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<Toolbar>()?.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()
}
}

View File

@@ -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<CoordinatorLayout.LayoutParams> {
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<SavedState> = object : Parcelable.ClassLoaderCreator<SavedState> {
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<SavedState> {
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
}
}

View File

@@ -81,22 +81,11 @@ class FavouritesContainerFragment :
} }
override fun onWindowInsetsChanged(insets: Insets) { 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 { binding.tabs.apply {
updatePadding( updatePadding(
left = insets.left, left = insets.left,
right = insets.right right = insets.right
) )
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
} }
} }

View File

@@ -185,10 +185,6 @@ abstract class MangaListFragment :
right = insets.right, right = insets.right,
) )
if (activity is MainActivity) { if (activity is MainActivity) {
binding.recyclerView.updatePadding(
top = headerHeight,
bottom = insets.bottom,
)
binding.swipeRefreshLayout.setProgressViewOffset( binding.swipeRefreshLayout.setProgressViewOffset(
true, true,
headerHeight + resources.resolveDp(-72), headerHeight + resources.resolveDp(-72),

View File

@@ -76,12 +76,9 @@ class FeedFragment :
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
top = headerHeight + paddingVertical,
left = insets.left + paddingHorizontal, left = insets.left + paddingHorizontal,
right = insets.right + paddingHorizontal, right = insets.right + paddingHorizontal,
bottom = insets.bottom + paddingVertical,
) )
} }

View File

@@ -9,14 +9,19 @@ import android.net.Network
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings
import android.view.ViewGroup
import android.view.ViewPropertyAnimator
import android.view.Window import android.view.Window
import android.view.animation.Animation
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.constraintlayout.motion.widget.MotionScene
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.children
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope import androidx.lifecycle.coroutineScope
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider import com.google.android.material.elevation.ElevationOverlayProvider
import kotlin.coroutines.resume
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -111,4 +116,15 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float
elevation, 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 <reified T> ViewGroup.findChild(): T? {
return children.find { it is T } as? T
} }

View File

@@ -15,7 +15,8 @@
<androidx.viewpager2.widget.ViewPager2 <androidx.viewpager2.widget.ViewPager2
android:id="@+id/pager" android:id="@+id/pager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent"
android:paddingVertical="8dp"/>
<ViewStub <ViewStub
android:id="@+id/stub_empty_state" android:id="@+id/stub_empty_state"