Edge-to-edge manga details view (#5613)
* Prepare for edge-to-edge MangaController * Fix derpy liftToScroll with our own implementation * Edge-to-edge MangaController Except when legacy blue theme is used. * Save app bar lift state for controller backstack * Fix expanded cover position after the view recycled * Handle overlap changes when incognito mode disabled * Tablet fixes * Revert "Handle overlap changes when incognito mode disabled" This reverts commit 1f492449 Breaks on rotation changes. * Fix MangaController's swipe refresh position * All controllers are now doing lift app bar on scroll by default They are already doing that before so this pretty much just a cleanups. * TachiyomiCoordinatorLayout: Support ViewPager for app bar lift state check I'm willing to revert this if this minute detail solution is deemed too hacky xD * Fix app bar not lifted when scrolled without fling * Save app bar lift state across configuration changes * Fix MangaController's swipe refresh position after configuration change * TachiyomiCoordinatorLayout: Update ViewPager reference when controller is changedpull/5749/head
parent
914b686c8e
commit
da16110e1c
@ -1,3 +1,3 @@
|
||||
package eu.kanade.tachiyomi.ui.base.controller
|
||||
|
||||
interface NoToolbarElevationController
|
||||
interface NoAppBarElevationController
|
@ -1,3 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.base.controller
|
||||
|
||||
interface ToolbarLiftOnScrollController
|
@ -1,47 +1,87 @@
|
||||
package eu.kanade.tachiyomi.widget
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.StateListAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.animation.AnimationUtils
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
class ElevationAppBarLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : AppBarLayout(context, attrs) {
|
||||
|
||||
private var origStateAnimator: StateListAnimator? = null
|
||||
private var lifted = true
|
||||
private var transparent = false
|
||||
|
||||
init {
|
||||
origStateAnimator = stateListAnimator
|
||||
}
|
||||
private val toolbar by lazy { findViewById<MaterialToolbar>(R.id.toolbar) }
|
||||
|
||||
fun enableElevation(liftOnScroll: Boolean) {
|
||||
setElevation(liftOnScroll)
|
||||
}
|
||||
private var elevationAnimator: ValueAnimator? = null
|
||||
private var backgroundAlphaAnimator: ValueAnimator? = null
|
||||
|
||||
private fun setElevation(liftOnScroll: Boolean) {
|
||||
stateListAnimator = origStateAnimator
|
||||
isLiftOnScroll = liftOnScroll
|
||||
}
|
||||
var isTransparentWhenNotLifted = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
updateBackgroundAlpha()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disabled. Lift on scroll is handled manually with [TachiyomiCoordinatorLayout]
|
||||
*/
|
||||
override fun isLiftOnScroll(): Boolean = false
|
||||
|
||||
fun disableElevation() {
|
||||
stateListAnimator = StateListAnimator().apply {
|
||||
val objAnimator = ObjectAnimator.ofFloat(this, "elevation", 0f)
|
||||
override fun isLifted(): Boolean = lifted
|
||||
|
||||
// Enabled and collapsible, but not collapsed means not elevated
|
||||
addState(
|
||||
intArrayOf(android.R.attr.enabled, R.attr.state_collapsible, -R.attr.state_collapsed),
|
||||
objAnimator
|
||||
)
|
||||
override fun setLifted(lifted: Boolean): Boolean {
|
||||
return if (this.lifted != lifted) {
|
||||
this.lifted = lifted
|
||||
val from = elevation
|
||||
val to = if (lifted) {
|
||||
resources.getDimension(R.dimen.design_appbar_elevation)
|
||||
} else {
|
||||
0F
|
||||
}
|
||||
|
||||
elevationAnimator?.cancel()
|
||||
elevationAnimator = ValueAnimator.ofFloat(from, to).apply {
|
||||
duration = resources.getInteger(R.integer.app_bar_elevation_anim_duration).toLong()
|
||||
interpolator = AnimationUtils.LINEAR_INTERPOLATOR
|
||||
addUpdateListener {
|
||||
elevation = it.animatedValue as Float
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
updateBackgroundAlpha()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Default enabled state
|
||||
addState(intArrayOf(android.R.attr.enabled), objAnimator)
|
||||
private fun updateBackgroundAlpha() {
|
||||
val newTransparent = if (lifted) false else isTransparentWhenNotLifted
|
||||
if (transparent != newTransparent) {
|
||||
transparent = newTransparent
|
||||
val fromAlpha = if (transparent) 255 else 0
|
||||
val toAlpha = if (transparent) 0 else 255
|
||||
|
||||
// Disabled state
|
||||
addState(IntArray(0), objAnimator)
|
||||
backgroundAlphaAnimator?.cancel()
|
||||
backgroundAlphaAnimator = ValueAnimator.ofInt(fromAlpha, toAlpha).apply {
|
||||
duration = resources.getInteger(R.integer.app_bar_elevation_anim_duration).toLong()
|
||||
interpolator = AnimationUtils.LINEAR_INTERPOLATOR
|
||||
addUpdateListener {
|
||||
val alpha = it.animatedValue as Int
|
||||
background.alpha = alpha
|
||||
toolbar?.background?.alpha = alpha
|
||||
statusBarForeground?.alpha = alpha
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,38 @@
|
||||
package eu.kanade.tachiyomi.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import com.bluelinelabs.conductor.ChangeHandlerFrameLayout
|
||||
|
||||
/**
|
||||
* [ChangeHandlerFrameLayout] with the ability to draw behind the header sibling in [CoordinatorLayout].
|
||||
* The layout behavior of this view is set to [TachiyomiScrollingViewBehavior] and should not be changed.
|
||||
*/
|
||||
class TachiyomiChangeHandlerFrameLayout(
|
||||
context: Context,
|
||||
attrs: AttributeSet
|
||||
) : ChangeHandlerFrameLayout(context, attrs), CoordinatorLayout.AttachedBehavior {
|
||||
|
||||
/**
|
||||
* If true, this view will draw behind the header sibling.
|
||||
*
|
||||
* @see TachiyomiScrollingViewBehavior.shouldHeaderOverlap
|
||||
*/
|
||||
var overlapHeader = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
(layoutParams as? CoordinatorLayout.LayoutParams)?.behavior = behavior.apply {
|
||||
shouldHeaderOverlap = value
|
||||
}
|
||||
if (!value) {
|
||||
// The behavior doesn't reset translationY when shouldHeaderOverlap is false
|
||||
translationY = 0F
|
||||
}
|
||||
forceLayout()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getBehavior() = TachiyomiScrollingViewBehavior()
|
||||
}
|
@ -0,0 +1,177 @@
|
||||
package eu.kanade.tachiyomi.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.coordinatorlayout.R
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.customview.view.AbsSavedState
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
import com.bluelinelabs.conductor.ChangeHandlerFrameLayout
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import eu.kanade.tachiyomi.util.system.isTablet
|
||||
import eu.kanade.tachiyomi.util.view.findChild
|
||||
import eu.kanade.tachiyomi.util.view.findDescendant
|
||||
import eu.kanade.tachiyomi.util.view.getActivePageView
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import reactivecircus.flowbinding.android.view.HierarchyChangeEvent
|
||||
import reactivecircus.flowbinding.android.view.hierarchyChangeEvents
|
||||
|
||||
/**
|
||||
* [CoordinatorLayout] with its own app bar lift state handler.
|
||||
* This parent view checks for the app bar lift state from the following:
|
||||
*
|
||||
* 1. When nested scroll detected, lift state will be decided from the nested
|
||||
* scroll target. (See [onNestedScroll])
|
||||
*
|
||||
* 2. When a descendant ViewPager active page is changed and the page contains RecyclerView,
|
||||
* lift state will be decided from the said RecyclerView. (See [pageChangeListener])
|
||||
*
|
||||
*
|
||||
* With those conditions, this view expects the following direct child:
|
||||
*
|
||||
* 1. An [AppBarLayout].
|
||||
*
|
||||
* 2. A [ChangeHandlerFrameLayout] that contains an optional [ViewPager].
|
||||
*/
|
||||
class TachiyomiCoordinatorLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.coordinatorLayoutStyle
|
||||
) : CoordinatorLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
/**
|
||||
* Keep lifted state and do nothing on tablet UI
|
||||
*/
|
||||
private val isTablet = context.isTablet()
|
||||
|
||||
private var appBarLayout: AppBarLayout? = null
|
||||
private var viewPager: ViewPager? = null
|
||||
set(value) {
|
||||
field?.removeOnPageChangeListener(pageChangeListener)
|
||||
field = value
|
||||
field?.addOnPageChangeListener(pageChangeListener)
|
||||
}
|
||||
|
||||
private val pageChangeListener = object : ViewPager.SimpleOnPageChangeListener() {
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
// Wait until idle to make sure all the views laid out properly before checked
|
||||
if (canLiftAppBarOnScroll && state == ViewPager.SCROLL_STATE_IDLE) {
|
||||
appBarLayout?.isLifted = (viewPager?.getActivePageView() as? ViewGroup)
|
||||
?.findDescendant<RecyclerView>()
|
||||
?.canScrollVertically(-1) ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, [AppBarLayout] child will be lifted on nested scroll.
|
||||
*/
|
||||
var isLiftAppBarOnScroll = true
|
||||
|
||||
/**
|
||||
* Internal check
|
||||
*/
|
||||
private val canLiftAppBarOnScroll
|
||||
get() = !isTablet && isLiftAppBarOnScroll
|
||||
|
||||
override fun onNestedScroll(
|
||||
target: View,
|
||||
dxConsumed: Int,
|
||||
dyConsumed: Int,
|
||||
dxUnconsumed: Int,
|
||||
dyUnconsumed: Int,
|
||||
type: Int,
|
||||
consumed: IntArray
|
||||
) {
|
||||
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed)
|
||||
if (canLiftAppBarOnScroll) {
|
||||
appBarLayout?.isLifted = dyConsumed != 0 || dyUnconsumed >= 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
appBarLayout = findChild()
|
||||
viewPager = findChild<ChangeHandlerFrameLayout>()?.findDescendant()
|
||||
|
||||
// Updates ViewPager reference when controller is changed
|
||||
findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.let { scope ->
|
||||
findChild<ChangeHandlerFrameLayout>()?.hierarchyChangeEvents()
|
||||
?.onEach {
|
||||
if (it is HierarchyChangeEvent.ChildRemoved) {
|
||||
viewPager = (it.parent as? ViewGroup)?.findDescendant()
|
||||
}
|
||||
}
|
||||
?.launchIn(scope)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
appBarLayout = null
|
||||
viewPager = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable? {
|
||||
val superState = super.onSaveInstanceState()
|
||||
return if (superState != null) {
|
||||
SavedState(superState).also {
|
||||
it.appBarLifted = appBarLayout?.isLifted ?: false
|
||||
}
|
||||
} else {
|
||||
superState
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
if (state is SavedState) {
|
||||
super.onRestoreInstanceState(state.superState)
|
||||
doOnLayout {
|
||||
appBarLayout?.isLifted = state.appBarLifted
|
||||
}
|
||||
} else {
|
||||
super.onRestoreInstanceState(state)
|
||||
}
|
||||
}
|
||||
|
||||
internal class SavedState : AbsSavedState {
|
||||
var appBarLifted = false
|
||||
|
||||
constructor(superState: Parcelable) : super(superState)
|
||||
|
||||
constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
|
||||
appBarLifted = source.readByte().toInt() == 1
|
||||
}
|
||||
|
||||
override fun writeToParcel(out: Parcel, flags: Int) {
|
||||
super.writeToParcel(out, flags)
|
||||
out.writeByte((if (appBarLifted) 1 else 0).toByte())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package eu.kanade.tachiyomi.widget
|
||||
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
|
||||
/**
|
||||
* [AppBarLayout.ScrollingViewBehavior] that lets the app bar overlaps the scrolling child.
|
||||
*/
|
||||
class TachiyomiScrollingViewBehavior : AppBarLayout.ScrollingViewBehavior() {
|
||||
|
||||
var shouldHeaderOverlap = false
|
||||
|
||||
override fun shouldHeaderOverlapScrollingChild(): Boolean {
|
||||
return shouldHeaderOverlap
|
||||
}
|
||||
}
|
Loading…
Reference in new issue