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 changed
This commit is contained in:
Ivan Iskandar
2021-08-19 20:12:52 +07:00
committed by GitHub
parent 914b686c8e
commit da16110e1c
20 changed files with 490 additions and 90 deletions

View File

@@ -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) }
private var elevationAnimator: ValueAnimator? = null
private var backgroundAlphaAnimator: ValueAnimator? = null
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
override fun isLifted(): Boolean = lifted
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
}
}
fun enableElevation(liftOnScroll: Boolean) {
setElevation(liftOnScroll)
}
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
private fun setElevation(liftOnScroll: Boolean) {
stateListAnimator = origStateAnimator
isLiftOnScroll = liftOnScroll
}
fun disableElevation() {
stateListAnimator = StateListAnimator().apply {
val objAnimator = ObjectAnimator.ofFloat(this, "elevation", 0f)
// Enabled and collapsible, but not collapsed means not elevated
addState(
intArrayOf(android.R.attr.enabled, R.attr.state_collapsible, -R.attr.state_collapsed),
objAnimator
)
// Default enabled state
addState(intArrayOf(android.R.attr.enabled), objAnimator)
// 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()
}
}
}
}

View File

@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.widget
import android.view.View
import android.view.ViewGroup
import androidx.viewpager.widget.ViewPager
import com.nightlynexus.viewstatepageradapter.ViewStatePagerAdapter
import java.util.Stack
@@ -22,7 +23,11 @@ abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() {
protected open fun recycleView(view: View, position: Int) {}
override fun createView(container: ViewGroup, position: Int): View {
val view = if (pool.isNotEmpty()) pool.pop() else createView(container)
val view = if (pool.isNotEmpty()) {
pool.pop().setViewPagerPositionParam(position)
} else {
createView(container)
}
bindView(view, position)
return view
}
@@ -31,4 +36,25 @@ abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() {
recycleView(view, position)
if (recycle) pool.push(view)
}
/**
* Making sure that this ViewPager child view has the correct "position" layout param
* after being recycled.
*/
private fun View.setViewPagerPositionParam(position: Int): View {
val params = layoutParams
if (params is ViewPager.LayoutParams) {
if (!params.isDecor) {
try {
val positionField = ViewPager.LayoutParams::class.java.getDeclaredField("position")
positionField.isAccessible = true
positionField.setInt(params, position)
layoutParams = params
} catch (e: NoSuchFieldException) {
} catch (e: IllegalAccessException) {
}
}
}
return this
}
}

View File

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

View File

@@ -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)
}
}
}
}
}

View File

@@ -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
}
}