From da16110e1c571526baea34275db41937f68c7e8c Mon Sep 17 00:00:00 2001 From: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> Date: Thu, 19 Aug 2021 20:12:52 +0700 Subject: [PATCH] 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 --- .../ui/base/controller/ConductorExtensions.kt | 8 + ...ller.kt => NoAppBarElevationController.kt} | 2 +- .../ToolbarLiftOnScrollController.kt | 3 - .../details/ExtensionDetailsController.kt | 4 +- .../tachiyomi/ui/library/LibraryController.kt | 7 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 59 +++--- .../tachiyomi/ui/manga/MangaController.kt | 40 +++- .../ui/manga/info/MangaInfoHeaderAdapter.kt | 13 ++ .../tachiyomi/ui/more/AboutController.kt | 4 +- .../tachiyomi/ui/more/MoreController.kt | 4 +- .../tachiyomi/util/view/ViewExtensions.kt | 41 ++++ .../tachiyomi/widget/ElevationAppBarLayout.kt | 98 +++++++--- .../widget/RecyclerViewPagerAdapter.kt | 28 ++- .../TachiyomiChangeHandlerFrameLayout.kt | 38 ++++ .../widget/TachiyomiCoordinatorLayout.kt | 177 ++++++++++++++++++ .../widget/TachiyomiScrollingViewBehavior.kt | 15 ++ .../main/res/layout-sw720dp/main_activity.xml | 7 +- app/src/main/res/layout/main_activity.xml | 22 ++- app/src/main/res/layout/manga_info_header.xml | 7 +- app/src/main/res/values/themes.xml | 3 +- 20 files changed, 490 insertions(+), 90 deletions(-) rename app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/{NoToolbarElevationController.kt => NoAppBarElevationController.kt} (55%) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ToolbarLiftOnScrollController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiChangeHandlerFrameLayout.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiScrollingViewBehavior.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ConductorExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ConductorExtensions.kt index a91acb5ea..17735cf49 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ConductorExtensions.kt +++ b/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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoToolbarElevationController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoAppBarElevationController.kt similarity index 55% rename from app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoToolbarElevationController.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoAppBarElevationController.kt index 74c9b47bb..c3fa65aa3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NoToolbarElevationController.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ToolbarLiftOnScrollController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ToolbarLiftOnScrollController.kt deleted file mode 100644 index e3b296b47..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/ToolbarLiftOnScrollController.kt +++ /dev/null @@ -1,3 +0,0 @@ -package eu.kanade.tachiyomi.ui.base.controller - -interface ToolbarLiftOnScrollController diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt index 3107c58e1..6ff4ae4de 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/details/ExtensionDetailsController.kt +++ b/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(bundle), - ToolbarLiftOnScrollController { + NucleusController(bundle) { private val preferences: PreferencesHelper by injectLazy() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index c336918dd..4b1df1274 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index f80bb3c5c..0f397e605 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/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() { } } - lateinit var tabAnimator: ViewHeightAnimator private var bottomNavAnimator: ViewHeightAnimator? = null private var isConfirmingExit: Boolean = false @@ -93,6 +93,11 @@ class MainActivity : BaseViewBindingActivity() { private var fixedViewsToBottom = mutableMapOf() + /** + * App bar lift state for backstack + */ + private val backstackLiftState = mutableMapOf() + // To be checked by splash screen. If true then splash screen will be removed. var ready = false @@ -117,11 +122,6 @@ class MainActivity : BaseViewBindingActivity() { // 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() { } setSplashScreenExitAnimation(splashScreen) - tabAnimator = ViewHeightAnimator(binding.tabs, 0L) - if (binding.bottomNav != null) { bottomNavAnimator = ViewHeightAnimator(binding.bottomNav!!) @@ -218,7 +216,7 @@ class MainActivity : BaseViewBindingActivity() { container: ViewGroup, handler: ControllerChangeHandler ) { - syncActivityViewWithController(to, from) + syncActivityViewWithController(to, from, isPush) } override fun onChangeCompleted( @@ -504,7 +502,7 @@ class MainActivity : BaseViewBindingActivity() { 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() { 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() { 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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index 9970ed5f9..d258032d3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/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, - 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 { + 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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt index bb8b17333..df3a24678 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt +++ b/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 { + 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt index babd399a3..27d63c4c3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt +++ b/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() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt index cef1736c6..6cc3e6020 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/MoreController.kt +++ b/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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt index d455d7f60..08eba0de5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt +++ b/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 ViewGroup.findChild(): T? { + return children.find { it is T } as? T +} + +/** + * Returns this ViewGroup's first descendant of specified class + */ +inline fun 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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt index ad0e52e03..5ef1fdff8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/ElevationAppBarLayout.kt +++ b/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(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() + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/RecyclerViewPagerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/RecyclerViewPagerAdapter.kt index 2e1d51c2f..2b429c5d0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/RecyclerViewPagerAdapter.kt +++ b/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 + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiChangeHandlerFrameLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiChangeHandlerFrameLayout.kt new file mode 100644 index 000000000..91aade2ee --- /dev/null +++ b/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() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiCoordinatorLayout.kt new file mode 100644 index 000000000..804208255 --- /dev/null +++ b/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() + ?.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()?.findDescendant() + + // Updates ViewPager reference when controller is changed + findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.let { scope -> + findChild()?.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 = object : Parcelable.ClassLoaderCreator { + 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 { + return newArray(size) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiScrollingViewBehavior.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/TachiyomiScrollingViewBehavior.kt new file mode 100644 index 000000000..a9ec76cef --- /dev/null +++ b/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 + } +} diff --git a/app/src/main/res/layout-sw720dp/main_activity.xml b/app/src/main/res/layout-sw720dp/main_activity.xml index e83094ff3..adc1e8ff3 100644 --- a/app/src/main/res/layout-sw720dp/main_activity.xml +++ b/app/src/main/res/layout-sw720dp/main_activity.xml @@ -1,5 +1,5 @@ - @@ -88,7 +89,7 @@ app:layout_constraintStart_toEndOf="@+id/side_nav" app:layout_constraintTop_toBottomOf="@+id/incognito_mode" /> - - + diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index a5ce51509..30814b92f 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -1,5 +1,5 @@ - + + + android:fitsSystemWindows="true" + app:elevation="0dp" + app:statusBarForeground="?attr/colorToolbar"> + android:layout_height="wrap_content" + android:visibility="gone" /> - - @@ -83,4 +85,4 @@ app:menu="@menu/main_nav" tools:ignore="KeyboardInaccessibleWidget" /> - + diff --git a/app/src/main/res/layout/manga_info_header.xml b/app/src/main/res/layout/manga_info_header.xml index cf333ede1..7d5616bca 100644 --- a/app/src/main/res/layout/manga_info_header.xml +++ b/app/src/main/res/layout/manga_info_header.xml @@ -25,17 +25,18 @@ + app:layout_constraintBottom_toBottomOf="@+id/backdrop" + app:layout_constraintTop_toTopOf="parent" /> @bool/lightStatusBar - ?attr/colorSurface + @android:color/transparent @color/surface_amoled @null false @@ -186,7 +186,6 @@ ?attr/lightSystemBarsOnPrimary ?attr/lightSystemBarsOnPrimary - ?attr/colorPrimary ?attr/colorPrimary