Skip to content

Commit

Permalink
[UI] Enable colored app bars on scroll (#211)
Browse files Browse the repository at this point in the history
* Extract fragment scroll listener to separate file

* Replace canRefresh with isScrolled

* Add empty helper class to animate app bar color

* Implement AppBarColorAnimator

* Rename getRefreshScrollingView() to getScrollingView()

* Set isScrolled on drag start

* Clear isScrolled on fling to top

* Add getSyncParams() to fragments

* Convert getAppBars() to property

* Tint TabLayout background drawable
  • Loading branch information
kuba2k2 authored Jul 9, 2024
1 parent 58d9dec commit 37a9459
Show file tree
Hide file tree
Showing 24 changed files with 254 additions and 100 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import kotlinx.coroutines.withContext
import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.data.enums.FeatureType
import pl.szczodrzynski.edziennik.data.enums.MetadataType
import pl.szczodrzynski.edziennik.databinding.FragmentAgendaCalendarBinding
import pl.szczodrzynski.edziennik.databinding.FragmentAgendaDefaultBinding
Expand All @@ -45,6 +46,7 @@ class AgendaFragment : BaseFragment<ViewBinding, MainActivity>(

override fun getFab() = R.string.add to CommunityMaterial.Icon3.cmd_plus
override fun getMarkAsReadType() = MetadataType.EVENT
override fun getSyncParams() = FeatureType.AGENDA to null
override fun getBottomSheetItems() = listOf(
BottomSheetPrimaryItem(true)
.withTitle(R.string.menu_add_event)
Expand Down Expand Up @@ -114,6 +116,7 @@ class AgendaFragment : BaseFragment<ViewBinding, MainActivity>(
private suspend fun createDefaultAgendaView(b: FragmentAgendaDefaultBinding) {
if (!isAdded)
return
canRefreshDisabled = true
checkEventTypes()
delay(500)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package pl.szczodrzynski.edziennik.ui.attendance
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.enums.FeatureType
import pl.szczodrzynski.edziennik.data.enums.MetadataType
import pl.szczodrzynski.edziennik.databinding.BasePagerFragmentBinding
import pl.szczodrzynski.edziennik.ext.Bundle
Expand All @@ -32,6 +33,7 @@ class AttendanceFragment : PagerFragment<BasePagerFragmentBinding, MainActivity>
}

override fun getMarkAsReadType() = MetadataType.ATTENDANCE
override fun getSyncParams() = FeatureType.ATTENDANCE to null
override fun getBottomSheetItems() = listOf(
BottomSheetPrimaryItem(true)
.withTitle(R.string.menu_attendance_config)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class AttendanceListFragment : BaseFragment<AttendanceListFragmentBinding, MainA
inflater = AttendanceListFragmentBinding::inflate,
) {

override fun getRefreshScrollingView() = b.list
override fun getScrollingView() = b.list

private var viewType = AttendanceFragment.VIEW_DAYS
private var expandSubjectId = 0L
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class AttendanceSummaryFragment : BaseFragment<AttendanceSummaryFragmentBinding,
private var periodSelection = 0
}

override fun getRefreshScrollingView() = b.scrollView
override fun getScrollingView() = b.scrollView

private val manager
get() = app.attendanceManager
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@

package pl.szczodrzynski.edziennik.ui.base.fragment

import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
Expand All @@ -18,49 +14,6 @@ import pl.szczodrzynski.edziennik.ui.login.LoginActivity
import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetPrimaryItem
import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetSeparatorItem

@SuppressLint("ClickableViewAccessibility")
internal fun BaseFragment<*, *>.setupCanRefresh() {
when (val view = getRefreshScrollingView()) {
is RecyclerView -> {
canRefresh = !view.canScrollVertically(-1)
view.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
// disable refresh when scrolled down
if (recyclerView.canScrollVertically(-1))
canRefresh = false
// enable refresh when scrolled to the top and not scrolling anymore
else if (newState == RecyclerView.SCROLL_STATE_IDLE)
canRefresh = true
}
})
}

is View -> {
canRefresh = !view.canScrollVertically(-1)
var isTouched = false
view.setOnTouchListener { _, event ->
// keep track of the touch state
when (event.action) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> isTouched = false
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> isTouched = true
}
// disable refresh when scrolled down
if (view.canScrollVertically(-1))
canRefresh = false
// enable refresh when scrolled to the top and not touching anymore
else if (!isTouched)
canRefresh = true
false
}
}

else -> {
// dispatch the default value to the activity
canRefresh = canRefresh
}
}
}

internal fun BaseFragment<*, *>.setupMainActivity(activity: MainActivity) {
val items = getBottomSheetItems().toMutableList()
getMarkAsReadType()?.let { metadataType ->
Expand Down Expand Up @@ -97,6 +50,8 @@ internal fun BaseFragment<*, *>.setupMainActivity(activity: MainActivity) {
}
}
}

appBars += activity.navView.toolbar
}

internal fun BaseFragment<*, *>.setupLoginActivity(activity: LoginActivity) {}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import com.google.gson.JsonObject
import com.mikepenz.iconics.typeface.IIcon
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.data.enums.FeatureType
import pl.szczodrzynski.edziennik.data.enums.MetadataType
import pl.szczodrzynski.edziennik.ext.registerSafe
import pl.szczodrzynski.edziennik.ext.startCoroutineTimer
Expand All @@ -38,31 +40,37 @@ abstract class BaseFragment<B : ViewBinding, A : AppCompatActivity>(

private var isViewReady: Boolean = false
private var inState: Bundle? = null
private var appBarAnimator: AppBarColorAnimator? = null

/**
* Enables or disables the activity's SwipeRefreshLayout.
* Use only if [getRefreshScrollingView] is not used.
*
* The [PagerFragment] manages its [canRefresh] state
* based on the value of the currently selected page.
* Whether the view is currently being scrolled
* or is left scrolled away from the top.
*/
internal var canRefresh = false
set(value) {
internal var isScrolled = false
set(value) { // cannot be private - PagerFragment onPageScrollStateChanged
field = value
(activity as? MainActivity)?.swipeRefreshLayout?.isEnabled =
!canRefreshDisabled && value
dispatchCanRefresh()
appBarAnimator?.dispatchLiftOnScroll()
}

/**
* Forcefully disables the activity's SwipeRefreshLayout
* if [getRefreshScrollingView] is used.
* Forcefully disables the activity's SwipeRefreshLayout.
*
* The [PagerFragment] manages its [canRefreshDisabled] state
* based on the value of the currently selected page.
*/
internal var canRefreshDisabled = false
set(value) {
field = value
canRefresh = canRefresh
dispatchCanRefresh()
}

/**
* A list of views (usually app bars) that should have their
* background color elevated when the fragment is scrolled.
*/
internal var appBars = mutableSetOf<View>()

private var job = Job()
final override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
Expand All @@ -83,6 +91,7 @@ abstract class BaseFragment<B : ViewBinding, A : AppCompatActivity>(
?: return null
isViewReady = false // reinitialize the view in onResume()
inState = savedInstanceState // save the instance state for onResume()
appBarAnimator = AppBarColorAnimator(activity, appBars)
return b.root
}

Expand All @@ -92,9 +101,17 @@ abstract class BaseFragment<B : ViewBinding, A : AppCompatActivity>(
if (!isAdded || isViewReady)
return
isViewReady = true
setupCanRefresh()
// setup the activity (bottom sheet, FAB, etc.)
// run before setupScrollListener {} to populate appBars
(activity as? MainActivity)?.let(::setupMainActivity)
(activity as? LoginActivity)?.let(::setupLoginActivity)
// listen to scroll state changes
var first = true
setupScrollListener {
if (isScrolled != it || first)
isScrolled = it
first = false
}
// let the UI transition for a moment
startCoroutineTimer(100L) {
if (!isAdded)
Expand Down Expand Up @@ -141,9 +158,10 @@ abstract class BaseFragment<B : ViewBinding, A : AppCompatActivity>(

/**
* Called to retrieve the scrolling view contained in the fragment.
* The scrolling view is configured to act nicely with the SwipeRefreshLayout.
* The scrolling view is configured to work nicely with the app bars
* and the SwipeRefreshLayout.
*/
open fun getRefreshScrollingView(): View? = null
open fun getScrollingView(): View? = null

/**
* Called to retrieve the FAB label resource and the icon.
Expand All @@ -157,6 +175,22 @@ abstract class BaseFragment<B : ViewBinding, A : AppCompatActivity>(
*/
open fun getMarkAsReadType(): MetadataType? = null

/**
* Called to retrieve the [FeatureType] this fragment is associated with.
* May also return arguments for the sync task.
*
* If not provided, swipe-to-refresh is disabled and the manual sync dialog
* selects all features by default.
*
* If [FeatureType] is null, all features are synced (and selected by the
* manual sync dialog).
*
* It is important to return the desired [FeatureType] from the first
* call of this method, which runs before [onViewReady]. Otherwise,
* swipe-to-refresh will not be enabled unless the view is scrolled.
*/
open fun getSyncParams(): Pair<FeatureType?, JsonObject?>? = null

/**
* Called to retrieve any extra bottom sheet items that should be displayed.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ abstract class PagerFragment<B : ViewBinding, A : AppCompatActivity>(
inflater: ((inflater: LayoutInflater, parent: ViewGroup?, attachToParent: Boolean) -> B)?,
) : BaseFragment<B, A>(inflater) {

private lateinit var pages: List<Pair<Fragment, String>>
private val fragmentCache = mutableMapOf<Int, Fragment>()
private lateinit var pages: List<Pair<BaseFragment<*, *>, String>>
private val fragmentCache = mutableMapOf<Int, BaseFragment<*, *>>()

/**
* Stores the default page index that is activated when
Expand All @@ -37,6 +37,18 @@ abstract class PagerFragment<B : ViewBinding, A : AppCompatActivity>(
*/
protected open var savedPageSelection = -1

protected val currentFragment: BaseFragment<*, *>?
get() = fragmentCache[getViewPager().currentItem]

final override fun getScrollingView() = null
override fun getSyncParams() = currentFragment?.getSyncParams()

override fun onResume() {
// add TabLayout before super's setupScrollListener {}
appBars += getTabLayout()
super.onResume()
}

override suspend fun onViewReady(savedInstanceState: Bundle?) {
if (savedPageSelection == -1)
savedPageSelection = savedInstanceState?.getInt("pageSelection") ?: 0
Expand All @@ -47,6 +59,7 @@ abstract class PagerFragment<B : ViewBinding, A : AppCompatActivity>(
override fun getItemCount() = getPageCount()
override fun createFragment(position: Int): Fragment {
val fragment = getPageFragment(position)
fragment.appBars += getTabLayout()
fragmentCache[position] = fragment
return fragment
}
Expand All @@ -58,15 +71,15 @@ abstract class PagerFragment<B : ViewBinding, A : AppCompatActivity>(
it.setCurrentItem(savedPageSelection, false)
it.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrollStateChanged(state: Int) {
canRefresh = when (state) {
ViewPager2.SCROLL_STATE_IDLE -> {
val fragment =
fragmentCache[it.currentItem] as? BaseFragment<*, *>
fragment != null && !fragment.canRefreshDisabled && fragment.canRefresh
}

else -> false
if (state != ViewPager2.SCROLL_STATE_IDLE) {
// disable swipe-to-refresh during scrolling
canRefreshDisabled = true
return
}
// take child fragment's values
val fragment = currentFragment
canRefreshDisabled = fragment?.canRefreshDisabled == true
isScrolled = fragment?.isScrolled == true
}

override fun onPageSelected(position: Int) {
Expand Down Expand Up @@ -125,7 +138,7 @@ abstract class PagerFragment<B : ViewBinding, A : AppCompatActivity>(
* Only used with the default implementation of [getPageCount], [getPageFragment]
* and [getPageTitle].
*/
open suspend fun onCreatePages() = listOf<Pair<Fragment, String>>()
open suspend fun onCreatePages() = listOf<Pair<BaseFragment<*, *>, String>>()

open fun getPageCount() = pages.size
open fun getPageFragment(position: Int) = pages[position].first
Expand Down
Loading

0 comments on commit 37a9459

Please sign in to comment.