Browse Source

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
Ivan Iskandar 3 years ago
parent
commit
da16110e1c
20 changed files with 487 additions and 87 deletions
  1. 8 0
      app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ConductorExtensions.kt
  2. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoAppBarElevationController.kt
  3. 0 3
      app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ToolbarLiftOnScrollController.kt
  4. 1 3
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt
  5. 1 6
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt
  6. 36 23
      app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
  7. 38 2
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
  8. 13 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt
  9. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt
  10. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt
  11. 41 0
      app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt
  12. 66 26
      app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt
  13. 27 1
      app/src/main/java/eu/kanade/tachiyomi/widget/RecyclerViewPagerAdapter.kt
  14. 38 0
      app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiChangeHandlerFrameLayout.kt
  15. 177 0
      app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt
  16. 15 0
      app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiScrollingViewBehavior.kt
  17. 4 3
      app/src/main/res/layout-sw720dp/main_activity.xml
  18. 12 10
      app/src/main/res/layout/main_activity.xml
  19. 4 3
      app/src/main/res/layout/manga_info_header.xml
  20. 1 2
      app/src/main/res/values/themes.xml

+ 8 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ConductorExtensions.kt

@@ -7,6 +7,7 @@ import androidx.core.net.toUri
 import com.bluelinelabs.conductor.Controller
 import com.bluelinelabs.conductor.Router
 import com.bluelinelabs.conductor.RouterTransaction
+import eu.kanade.tachiyomi.ui.main.MainActivity
 import eu.kanade.tachiyomi.util.system.toast
 
 fun Router.popControllerWithTag(tag: String): Boolean {
@@ -41,3 +42,10 @@ fun Controller.openInBrowser(url: String) {
         activity?.toast(e.message)
     }
 }
+
+/**
+ * Returns [MainActivity]'s app bar height
+ */
+fun Controller.getMainAppBarHeight(): Int {
+    return (activity as? MainActivity)?.binding?.appbar?.measuredHeight ?: 0
+}

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoToolbarElevationController.kt → app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoAppBarElevationController.kt

@@ -1,3 +1,3 @@
 package eu.kanade.tachiyomi.ui.base.controller
 
-interface NoToolbarElevationController
+interface NoAppBarElevationController

+ 0 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ToolbarLiftOnScrollController.kt

@@ -1,3 +0,0 @@
-package eu.kanade.tachiyomi.ui.base.controller
-
-interface ToolbarLiftOnScrollController

+ 1 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt

@@ -34,7 +34,6 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
 import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.source.getPreferenceKey
 import eu.kanade.tachiyomi.ui.base.controller.NucleusController
-import eu.kanade.tachiyomi.ui.base.controller.ToolbarLiftOnScrollController
 import eu.kanade.tachiyomi.ui.base.controller.openInBrowser
 import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
 import eu.kanade.tachiyomi.util.preference.DSL
@@ -49,8 +48,7 @@ import uy.kohesive.injekt.injectLazy
 
 @SuppressLint("RestrictedApi")
 class ExtensionDetailsController(bundle: Bundle? = null) :
-    NucleusController<ExtensionDetailControllerBinding, ExtensionDetailsPresenter>(bundle),
-    ToolbarLiftOnScrollController {
+    NucleusController<ExtensionDetailControllerBinding, ExtensionDetailsPresenter>(bundle) {
 
     private val preferences: PreferencesHelper by injectLazy()
 

+ 1 - 6
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt

@@ -245,12 +245,7 @@ class LibraryController(
         }
         tabsVisibilitySubscription?.unsubscribe()
         tabsVisibilitySubscription = tabsVisibilityRelay.subscribe { visible ->
-            val tabAnimator = (activity as? MainActivity)?.tabAnimator
-            if (visible) {
-                tabAnimator?.expand()
-            } else {
-                tabAnimator?.collapse()
-            }
+            tabs.isVisible = visible
         }
         mangaCountVisibilitySubscription?.unsubscribe()
         mangaCountVisibilitySubscription = mangaCountVisibilityRelay.subscribe {

+ 36 - 23
app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

@@ -34,6 +34,7 @@ import eu.kanade.tachiyomi.BuildConfig
 import eu.kanade.tachiyomi.Migrations
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.notification.NotificationReceiver
+import eu.kanade.tachiyomi.data.preference.PreferenceValues
 import eu.kanade.tachiyomi.data.preference.asImmediateFlow
 import eu.kanade.tachiyomi.data.updater.AppUpdateChecker
 import eu.kanade.tachiyomi.data.updater.AppUpdateResult
@@ -42,10 +43,9 @@ import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
 import eu.kanade.tachiyomi.ui.base.activity.BaseViewBindingActivity
 import eu.kanade.tachiyomi.ui.base.controller.DialogController
 import eu.kanade.tachiyomi.ui.base.controller.FabController
-import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
+import eu.kanade.tachiyomi.ui.base.controller.NoAppBarElevationController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
 import eu.kanade.tachiyomi.ui.base.controller.TabbedController
-import eu.kanade.tachiyomi.ui.base.controller.ToolbarLiftOnScrollController
 import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
 import eu.kanade.tachiyomi.ui.browse.BrowseController
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
@@ -61,6 +61,7 @@ import eu.kanade.tachiyomi.ui.setting.SettingsMainController
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.lang.launchUI
 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
@@ -85,7 +86,6 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
         }
     }
 
-    lateinit var tabAnimator: ViewHeightAnimator
     private var bottomNavAnimator: ViewHeightAnimator? = null
 
     private var isConfirmingExit: Boolean = false
@@ -93,6 +93,11 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
 
     private var fixedViewsToBottom = mutableMapOf<View, AppBarLayout.OnOffsetChangedListener>()
 
+    /**
+     * App bar lift state for backstack
+     */
+    private val backstackLiftState = mutableMapOf<String, Boolean>()
+
     // To be checked by splash screen. If true then splash screen will be removed.
     var ready = false
 
@@ -117,11 +122,6 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
 
         // Draw edge-to-edge
         WindowCompat.setDecorFitsSystemWindows(window, false)
-        binding.appbar.applyInsetter {
-            type(navigationBars = true, statusBars = true) {
-                padding(left = true, top = true, right = true)
-            }
-        }
         binding.fabLayout.rootFab.applyInsetter {
             type(navigationBars = true) {
                 margin()
@@ -140,8 +140,6 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
         }
         setSplashScreenExitAnimation(splashScreen)
 
-        tabAnimator = ViewHeightAnimator(binding.tabs, 0L)
-
         if (binding.bottomNav != null) {
             bottomNavAnimator = ViewHeightAnimator(binding.bottomNav!!)
 
@@ -218,7 +216,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
                     container: ViewGroup,
                     handler: ControllerChangeHandler
                 ) {
-                    syncActivityViewWithController(to, from)
+                    syncActivityViewWithController(to, from, isPush)
                 }
 
                 override fun onChangeCompleted(
@@ -504,7 +502,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
         router.setRoot(controller.withFadeTransaction().tag(id.toString()))
     }
 
-    private fun syncActivityViewWithController(to: Controller?, from: Controller? = null) {
+    private fun syncActivityViewWithController(to: Controller?, from: Controller? = null, isPush: Boolean = true) {
         if (from is DialogController || to is DialogController) {
             return
         }
@@ -529,12 +527,11 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
             from.cleanupTabs(binding.tabs)
         }
         if (to is TabbedController) {
-            tabAnimator.expand()
             to.configureTabs(binding.tabs)
         } else {
-            tabAnimator.collapse()
             binding.tabs.setupWithViewPager(null)
         }
+        binding.tabs.isVisible = to is TabbedController
 
         if (from is FabController) {
             binding.fabLayout.rootFab.isVisible = false
@@ -545,16 +542,32 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
             to.configureFab(binding.fabLayout.rootFab)
         }
 
-        when (to) {
-            is NoToolbarElevationController -> {
-                binding.appbar.disableElevation()
-            }
-            is ToolbarLiftOnScrollController -> {
-                binding.appbar.enableElevation(true)
-            }
-            else -> {
-                binding.appbar.enableElevation(false)
+        if (!isTablet()) {
+            // Save lift state
+            if (isPush) {
+                if (router.backstackSize > 1) {
+                    // Save lift state
+                    from?.let {
+                        backstackLiftState[it.instanceId] = binding.appbar.isLifted
+                    }
+                } else {
+                    backstackLiftState.clear()
+                }
+                binding.appbar.isLifted = false
+            } else {
+                to?.let {
+                    binding.appbar.isLifted = backstackLiftState.getOrElse(it.instanceId) { false }
+                }
+                from?.let {
+                    backstackLiftState.remove(it.instanceId)
+                }
             }
+
+            binding.root.isLiftAppBarOnScroll = to !is NoAppBarElevationController
+
+            binding.appbar.isTransparentWhenNotLifted = to is MangaController &&
+                preferences.appTheme().get() != PreferenceValues.AppTheme.BLUE
+            binding.controllerContainer.overlapHeader = to is MangaController
         }
     }
 

+ 38 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt

@@ -13,16 +13,21 @@ import android.view.Menu
 import android.view.MenuInflater
 import android.view.MenuItem
 import android.view.View
+import android.view.ViewGroup
 import android.widget.TextView
 import androidx.annotation.FloatRange
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.view.ActionMode
 import androidx.core.os.bundleOf
+import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.children
+import androidx.core.view.doOnLayout
 import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
 import androidx.recyclerview.widget.ConcatAdapter
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
 import coil.imageLoader
 import coil.request.ImageRequest
 import com.bluelinelabs.conductor.ControllerChangeHandler
@@ -51,7 +56,7 @@ import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.online.HttpSource
 import eu.kanade.tachiyomi.ui.base.controller.FabController
 import eu.kanade.tachiyomi.ui.base.controller.NucleusController
-import eu.kanade.tachiyomi.ui.base.controller.ToolbarLiftOnScrollController
+import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight
 import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
 import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
@@ -89,6 +94,7 @@ import eu.kanade.tachiyomi.util.view.snack
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import reactivecircus.flowbinding.recyclerview.scrollEvents
+import reactivecircus.flowbinding.recyclerview.scrollStateChanges
 import reactivecircus.flowbinding.swiperefreshlayout.refreshes
 import timber.log.Timber
 import uy.kohesive.injekt.Injekt
@@ -99,7 +105,6 @@ import kotlin.math.min
 
 class MangaController :
     NucleusController<MangaControllerBinding, MangaPresenter>,
-    ToolbarLiftOnScrollController,
     FabController,
     ActionMode.Callback,
     FlexibleAdapter.OnItemClickListener,
@@ -254,6 +259,37 @@ class MangaController :
                     updateToolbarTitleAlpha()
                 }
             }
+
+            it.scrollStateChanges()
+                .onEach { _ ->
+                    // Disable swipe refresh when view is not at the top
+                    val firstPos = (it.layoutManager as LinearLayoutManager)
+                        .findFirstCompletelyVisibleItemPosition()
+                    binding.swipeRefresh.isEnabled = firstPos <= 0
+                }
+                .launchIn(viewScope)
+
+            binding.fastScroller.doOnLayout { scroller ->
+                scroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+                    topMargin = getMainAppBarHeight()
+                }
+                scroller.applyInsetter {
+                    type(navigationBars = true) {
+                        margin()
+                    }
+                }
+            }
+
+            binding.swipeRefresh.doOnLayout { swipeRefresh ->
+                swipeRefresh as SwipeRefreshLayout
+                swipeRefresh.setOnApplyWindowInsetsListener { _, windowInsets ->
+                    val topStatusBarInset = WindowInsetsCompat.toWindowInsetsCompat(windowInsets)
+                        .getInsets(WindowInsetsCompat.Type.statusBars())
+                        .top
+                    swipeRefresh.setProgressViewEndTarget(false, getMainAppBarHeight() + topStatusBarInset)
+                    windowInsets
+                }
+            }
         }
         // Tablet layout
         binding.infoRecycler?.let {

+ 13 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt

@@ -3,7 +3,9 @@ package eu.kanade.tachiyomi.ui.manga.info
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
+import androidx.constraintlayout.widget.ConstraintLayout
 import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
 import androidx.recyclerview.widget.RecyclerView
 import coil.loadAny
 import coil.target.ImageViewTarget
@@ -16,6 +18,7 @@ import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.model.SManga
 import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.ui.base.controller.getMainAppBarHeight
 import eu.kanade.tachiyomi.ui.manga.MangaController
 import eu.kanade.tachiyomi.util.system.copyToClipboard
 import eu.kanade.tachiyomi.util.view.setChips
@@ -47,6 +50,7 @@ class MangaInfoHeaderAdapter(
 
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
         binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+        updateCoverPosition()
         return HeaderViewHolder(binding.root)
     }
 
@@ -75,6 +79,15 @@ class MangaInfoHeaderAdapter(
         notifyDataSetChanged()
     }
 
+    private fun updateCoverPosition() {
+        val appBarHeight = controller.getMainAppBarHeight()
+        binding.mangaCover.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+            topMargin += appBarHeight
+        }
+        binding.root.getConstraintSet(R.id.end)
+            ?.setMargin(R.id.manga_cover, ConstraintLayout.LayoutParams.TOP, appBarHeight)
+    }
+
     inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
         fun bind() {
             // For rounded corners

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt

@@ -5,7 +5,7 @@ import eu.kanade.tachiyomi.BuildConfig
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.updater.AppUpdateChecker
 import eu.kanade.tachiyomi.data.updater.AppUpdateResult
-import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
+import eu.kanade.tachiyomi.ui.base.controller.NoAppBarElevationController
 import eu.kanade.tachiyomi.ui.base.controller.openInBrowser
 import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
 import eu.kanade.tachiyomi.ui.more.licenses.LicensesController
@@ -25,7 +25,7 @@ import java.text.SimpleDateFormat
 import java.util.Locale
 import java.util.TimeZone
 
-class AboutController : SettingsController(), NoToolbarElevationController {
+class AboutController : SettingsController(), NoAppBarElevationController {
 
     private val updateChecker by lazy { AppUpdateChecker() }
 

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt

@@ -9,7 +9,7 @@ import androidx.preference.PreferenceScreen
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.download.DownloadManager
 import eu.kanade.tachiyomi.data.download.DownloadService
-import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
+import eu.kanade.tachiyomi.ui.base.controller.NoAppBarElevationController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
 import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
 import eu.kanade.tachiyomi.ui.category.CategoryController
@@ -41,7 +41,7 @@ import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
 class MoreController :
     SettingsController(),
     RootController,
-    NoToolbarElevationController {
+    NoAppBarElevationController {
 
     private val downloadManager: DownloadManager by injectLazy()
     private var isDownloading: Boolean = false

+ 41 - 0
app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt

@@ -9,6 +9,7 @@ import android.view.Gravity
 import android.view.Menu
 import android.view.MenuItem
 import android.view.View
+import android.view.ViewGroup
 import android.widget.TextView
 import androidx.annotation.MenuRes
 import androidx.annotation.StringRes
@@ -16,8 +17,11 @@ import androidx.appcompat.view.menu.MenuBuilder
 import androidx.appcompat.widget.PopupMenu
 import androidx.appcompat.widget.TooltipCompat
 import androidx.core.content.ContextCompat
+import androidx.core.view.children
+import androidx.core.view.descendants
 import androidx.core.view.forEach
 import androidx.recyclerview.widget.RecyclerView
+import androidx.viewpager.widget.ViewPager
 import com.google.android.material.card.MaterialCardView
 import com.google.android.material.chip.Chip
 import com.google.android.material.chip.ChipGroup
@@ -214,3 +218,40 @@ fun RecyclerView.onAnimationsFinished(callback: (RecyclerView) -> Unit) = post(
         }
     }
 )
+
+/**
+ * Returns this ViewGroup's first child of specified class
+ */
+inline fun <reified T> ViewGroup.findChild(): T? {
+    return children.find { it is T } as? T
+}
+
+/**
+ * Returns this ViewGroup's first descendant of specified class
+ */
+inline fun <reified T> ViewGroup.findDescendant(): T? {
+    return descendants.find { it is T } as? T
+}
+
+/**
+ * Returns the active child view of a ViewPager according to the LayoutParams
+ */
+fun ViewPager.getActivePageView(): View? {
+    if (null == adapter || adapter?.count == 0 || childCount == 0) {
+        return null
+    }
+
+    val positionField = ViewPager.LayoutParams::class.java.getDeclaredField("position")
+    positionField.isAccessible = true
+    return children.find { child ->
+        val layoutParams = child.layoutParams as ViewPager.LayoutParams
+        try {
+            if (!layoutParams.isDecor && positionField.getInt(layoutParams) == currentItem) {
+                return@find true
+            }
+        } catch (e: NoSuchFieldException) {
+        } catch (e: IllegalAccessException) {
+        }
+        false
+    }
+}

+ 66 - 26
app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt

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

+ 27 - 1
app/src/main/java/eu/kanade/tachiyomi/widget/RecyclerViewPagerAdapter.kt

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

+ 38 - 0
app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiChangeHandlerFrameLayout.kt

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

+ 177 - 0
app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt

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

+ 15 - 0
app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiScrollingViewBehavior.kt

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

+ 4 - 3
app/src/main/res/layout-sw720dp/main_activity.xml

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<eu.kanade.tachiyomi.widget.TachiyomiCoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
     android:id="@+id/root_coordinator"
@@ -15,6 +15,7 @@
             android:id="@+id/appbar"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
+            android:fitsSystemWindows="true"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
             app:layout_constraintTop_toTopOf="parent">
@@ -88,7 +89,7 @@
             app:layout_constraintStart_toEndOf="@+id/side_nav"
             app:layout_constraintTop_toBottomOf="@+id/incognito_mode" />
 
-        <com.bluelinelabs.conductor.ChangeHandlerFrameLayout
+        <eu.kanade.tachiyomi.widget.TachiyomiChangeHandlerFrameLayout
             android:id="@+id/controller_container"
             android:layout_width="0dp"
             android:layout_height="0dp"
@@ -103,4 +104,4 @@
         android:id="@+id/fab_layout"
         layout="@layout/main_activity_fab" />
 
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
+</eu.kanade.tachiyomi.widget.TachiyomiCoordinatorLayout>

+ 12 - 10
app/src/main/res/layout/main_activity.xml

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<eu.kanade.tachiyomi.widget.TachiyomiCoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
     android:id="@+id/root_coordinator"
@@ -7,11 +7,18 @@
     android:layout_height="match_parent"
     android:orientation="vertical">
 
+    <eu.kanade.tachiyomi.widget.TachiyomiChangeHandlerFrameLayout
+        android:id="@+id/controller_container"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
     <eu.kanade.tachiyomi.widget.ElevationAppBarLayout
         android:id="@+id/appbar"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:fitsSystemWindows="true">
+        android:fitsSystemWindows="true"
+        app:elevation="0dp"
+        app:statusBarForeground="?attr/colorToolbar">
 
         <com.google.android.material.appbar.MaterialToolbar
             android:id="@+id/toolbar"
@@ -23,7 +30,8 @@
         <com.google.android.material.tabs.TabLayout
             android:id="@+id/tabs"
             android:layout_width="match_parent"
-            android:layout_height="wrap_content" />
+            android:layout_height="wrap_content"
+            android:visibility="gone" />
 
         <FrameLayout
             android:id="@+id/downloaded_only"
@@ -63,12 +71,6 @@
 
     </eu.kanade.tachiyomi.widget.ElevationAppBarLayout>
 
-    <com.bluelinelabs.conductor.ChangeHandlerFrameLayout
-        android:id="@+id/controller_container"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        app:layout_behavior="@string/appbar_scrolling_view_behavior" />
-
     <include
         android:id="@+id/fab_layout"
         layout="@layout/main_activity_fab" />
@@ -83,4 +85,4 @@
         app:menu="@menu/main_nav"
         tools:ignore="KeyboardInaccessibleWidget" />
 
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
+</eu.kanade.tachiyomi.widget.TachiyomiCoordinatorLayout>

+ 4 - 3
app/src/main/res/layout/manga_info_header.xml

@@ -25,17 +25,18 @@
     <View
         android:id="@+id/backdrop_overlay"
         android:layout_width="match_parent"
-        android:layout_height="160dp"
+        android:layout_height="0dp"
         android:background="@drawable/manga_info_gradient"
         android:backgroundTint="?android:attr/colorBackground"
-        app:layout_constraintBottom_toBottomOf="@+id/backdrop" />
+        app:layout_constraintBottom_toBottomOf="@+id/backdrop"
+        app:layout_constraintTop_toTopOf="parent" />
 
     <ImageView
         android:id="@+id/manga_cover"
         android:layout_width="100dp"
         android:layout_height="0dp"
         android:layout_marginStart="16dp"
-        android:layout_marginTop="48dp"
+        android:layout_marginTop="8dp"
         android:background="@drawable/rounded_rectangle"
         android:contentDescription="@string/description_cover"
         android:maxWidth="100dp"

+ 1 - 2
app/src/main/res/values/themes.xml

@@ -36,7 +36,7 @@
 
         <!-- Themes -->
         <item name="android:windowLightStatusBar">@bool/lightStatusBar</item>
-        <item name="android:statusBarColor">?attr/colorSurface</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
         <item name="android:navigationBarColor">@color/surface_amoled</item>
         <item name="android:navigationBarDividerColor" tools:targetApi="o_mr1">@null</item>
         <item name="android:enforceNavigationBarContrast" tools:targetApi="Q">false</item>
@@ -186,7 +186,6 @@
         <!-- Status/Navigation bar -->
         <item name="android:windowLightStatusBar" tools:targetApi="m">?attr/lightSystemBarsOnPrimary</item>
         <item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">?attr/lightSystemBarsOnPrimary</item>
-        <item name="android:statusBarColor">?attr/colorPrimary</item>
         <item name="android:navigationBarColor">?attr/colorPrimary</item>
     </style>