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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 490 additions and 90 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,6 +34,7 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.Migrations import eu.kanade.tachiyomi.Migrations
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.NotificationReceiver 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.preference.asImmediateFlow
import eu.kanade.tachiyomi.data.updater.AppUpdateChecker import eu.kanade.tachiyomi.data.updater.AppUpdateChecker
import eu.kanade.tachiyomi.data.updater.AppUpdateResult 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.activity.BaseViewBindingActivity
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.FabController 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.RootController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController 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.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.BrowseController import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController 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.launchIO
import eu.kanade.tachiyomi.util.lang.launchUI import eu.kanade.tachiyomi.util.lang.launchUI
import eu.kanade.tachiyomi.util.system.dpToPx 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.system.toast
import eu.kanade.tachiyomi.util.view.setNavigationBarTransparentCompat import eu.kanade.tachiyomi.util.view.setNavigationBarTransparentCompat
import eu.kanade.tachiyomi.widget.HideBottomNavigationOnScrollBehavior 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 bottomNavAnimator: ViewHeightAnimator? = null
private var isConfirmingExit: Boolean = false private var isConfirmingExit: Boolean = false
@ -93,6 +93,11 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
private var fixedViewsToBottom = mutableMapOf<View, AppBarLayout.OnOffsetChangedListener>() 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. // To be checked by splash screen. If true then splash screen will be removed.
var ready = false var ready = false
@ -117,11 +122,6 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
// Draw edge-to-edge // Draw edge-to-edge
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
binding.appbar.applyInsetter {
type(navigationBars = true, statusBars = true) {
padding(left = true, top = true, right = true)
}
}
binding.fabLayout.rootFab.applyInsetter { binding.fabLayout.rootFab.applyInsetter {
type(navigationBars = true) { type(navigationBars = true) {
margin() margin()
@ -140,8 +140,6 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
} }
setSplashScreenExitAnimation(splashScreen) setSplashScreenExitAnimation(splashScreen)
tabAnimator = ViewHeightAnimator(binding.tabs, 0L)
if (binding.bottomNav != null) { if (binding.bottomNav != null) {
bottomNavAnimator = ViewHeightAnimator(binding.bottomNav!!) bottomNavAnimator = ViewHeightAnimator(binding.bottomNav!!)
@ -218,7 +216,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
container: ViewGroup, container: ViewGroup,
handler: ControllerChangeHandler handler: ControllerChangeHandler
) { ) {
syncActivityViewWithController(to, from) syncActivityViewWithController(to, from, isPush)
} }
override fun onChangeCompleted( override fun onChangeCompleted(
@ -504,7 +502,7 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
router.setRoot(controller.withFadeTransaction().tag(id.toString())) 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) { if (from is DialogController || to is DialogController) {
return return
} }
@ -529,12 +527,11 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
from.cleanupTabs(binding.tabs) from.cleanupTabs(binding.tabs)
} }
if (to is TabbedController) { if (to is TabbedController) {
tabAnimator.expand()
to.configureTabs(binding.tabs) to.configureTabs(binding.tabs)
} else { } else {
tabAnimator.collapse()
binding.tabs.setupWithViewPager(null) binding.tabs.setupWithViewPager(null)
} }
binding.tabs.isVisible = to is TabbedController
if (from is FabController) { if (from is FabController) {
binding.fabLayout.rootFab.isVisible = false binding.fabLayout.rootFab.isVisible = false
@ -545,16 +542,32 @@ class MainActivity : BaseViewBindingActivity<MainActivityBinding>() {
to.configureFab(binding.fabLayout.rootFab) to.configureFab(binding.fabLayout.rootFab)
} }
when (to) { if (!isTablet()) {
is NoToolbarElevationController -> { // Save lift state
binding.appbar.disableElevation() if (isPush) {
} if (router.backstackSize > 1) {
is ToolbarLiftOnScrollController -> { // Save lift state
binding.appbar.enableElevation(true) from?.let {
} backstackLiftState[it.instanceId] = binding.appbar.isLifted
else -> { }
binding.appbar.enableElevation(false) } 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
} }
} }

View file

@ -13,16 +13,21 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.doOnLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import com.bluelinelabs.conductor.ControllerChangeHandler 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.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.FabController import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController 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.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController import eu.kanade.tachiyomi.ui.browse.migration.search.SearchController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.recyclerview.scrollEvents import reactivecircus.flowbinding.recyclerview.scrollEvents
import reactivecircus.flowbinding.recyclerview.scrollStateChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes import reactivecircus.flowbinding.swiperefreshlayout.refreshes
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -99,7 +105,6 @@ import kotlin.math.min
class MangaController : class MangaController :
NucleusController<MangaControllerBinding, MangaPresenter>, NucleusController<MangaControllerBinding, MangaPresenter>,
ToolbarLiftOnScrollController,
FabController, FabController,
ActionMode.Callback, ActionMode.Callback,
FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemClickListener,
@ -254,6 +259,37 @@ class MangaController :
updateToolbarTitleAlpha() 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 // Tablet layout
binding.infoRecycler?.let { binding.infoRecycler?.let {

View file

@ -3,7 +3,9 @@ package eu.kanade.tachiyomi.ui.manga.info
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.loadAny import coil.loadAny
import coil.target.ImageViewTarget 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.SourceManager
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource 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.ui.manga.MangaController
import eu.kanade.tachiyomi.util.system.copyToClipboard import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.view.setChips import eu.kanade.tachiyomi.util.view.setChips
@ -47,6 +50,7 @@ class MangaInfoHeaderAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding = MangaInfoHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
updateCoverPosition()
return HeaderViewHolder(binding.root) return HeaderViewHolder(binding.root)
} }
@ -75,6 +79,15 @@ class MangaInfoHeaderAdapter(
notifyDataSetChanged() 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) { inner class HeaderViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind() { fun bind() {
// For rounded corners // For rounded corners

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import android.view.Gravity
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.annotation.MenuRes import androidx.annotation.MenuRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
@ -16,8 +17,11 @@ import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.TooltipCompat import androidx.appcompat.widget.TooltipCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.forEach import androidx.core.view.forEach
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager.widget.ViewPager
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup 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
}
}

View file

@ -1,47 +1,87 @@
package eu.kanade.tachiyomi.widget package eu.kanade.tachiyomi.widget
import android.animation.ObjectAnimator import android.animation.ValueAnimator
import android.animation.StateListAnimator
import android.content.Context import android.content.Context
import android.util.AttributeSet 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.AppBarLayout
import com.google.android.material.appbar.MaterialToolbar
import eu.kanade.tachiyomi.R
class ElevationAppBarLayout @JvmOverloads constructor( class ElevationAppBarLayout @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null attrs: AttributeSet? = null
) : AppBarLayout(context, attrs) { ) : AppBarLayout(context, attrs) {
private var origStateAnimator: StateListAnimator? = null private var lifted = true
private var transparent = false
init { private val toolbar by lazy { findViewById<MaterialToolbar>(R.id.toolbar) }
origStateAnimator = stateListAnimator
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) { private fun updateBackgroundAlpha() {
setElevation(liftOnScroll) 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) { backgroundAlphaAnimator?.cancel()
stateListAnimator = origStateAnimator backgroundAlphaAnimator = ValueAnimator.ofInt(fromAlpha, toAlpha).apply {
isLiftOnScroll = liftOnScroll duration = resources.getInteger(R.integer.app_bar_elevation_anim_duration).toLong()
} interpolator = AnimationUtils.LINEAR_INTERPOLATOR
addUpdateListener {
fun disableElevation() { val alpha = it.animatedValue as Int
stateListAnimator = StateListAnimator().apply { background.alpha = alpha
val objAnimator = ObjectAnimator.ofFloat(this, "elevation", 0f) toolbar?.background?.alpha = alpha
statusBarForeground?.alpha = alpha
// Enabled and collapsible, but not collapsed means not elevated }
addState( start()
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)
} }
} }
} }

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.widget
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.viewpager.widget.ViewPager
import com.nightlynexus.viewstatepageradapter.ViewStatePagerAdapter import com.nightlynexus.viewstatepageradapter.ViewStatePagerAdapter
import java.util.Stack import java.util.Stack
@ -22,7 +23,11 @@ abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() {
protected open fun recycleView(view: View, position: Int) {} protected open fun recycleView(view: View, position: Int) {}
override fun createView(container: ViewGroup, position: Int): View { 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) bindView(view, position)
return view return view
} }
@ -31,4 +36,25 @@ abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() {
recycleView(view, position) recycleView(view, position)
if (recycle) pool.push(view) 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
}
}

View file

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

View file

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

View file

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

View file

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