[UI] Implement RecyclerTabLayout, enable swipe-to-refresh in timetable (#209)

* Enable swipe-to-refresh in timetable

* Add basic RecyclerTabLayout view

* Implement tab scrolling in RecyclerTabLayout

* Implement tab clicking in RecyclerTabLayout

* Add selected tab indicator to RecyclerTabLayout

* Add ProGuard rules for RecyclerTabLayout

* Set RecyclerTabLayout background
This commit is contained in:
Kuba Szczodrzyński 2024-07-07 22:18:00 +02:00 committed by GitHub
parent 6371d71b7a
commit f10bc42c7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 279 additions and 86 deletions

View File

@ -55,6 +55,18 @@
setIcons(android.widget.TextView);
}
# for RecyclerTabView
-keepclassmembernames class com.google.android.material.tabs.TabLayout {
tabIndicatorInterpolator;
}
-keepclassmembernames class com.google.android.material.tabs.TabLayout$TabView {
tab;
updateTab();
}
-keepclassmembernames class com.google.android.material.tabs.TabIndicatorInterpolator {
updateIndicatorForOffset(com.google.android.material.tabs.TabLayout, android.view.View, android.view.View, float, android.graphics.drawable.Drawable);
}
-keep class .R
-keep class **.R$* {
<fields>;

View File

@ -0,0 +1,32 @@
/*
* Copyright (c) Kuba Szczodrzyński 2024-7-6.
*/
@file:Suppress("UNCHECKED_CAST")
package pl.szczodrzynski.edziennik.ext
fun <T> Any.getDeclaredField(name: String): T? = this::class.java.getDeclaredField(name).run {
isAccessible = true
get(this@getDeclaredField) as? T
}
fun Any.setDeclaredField(name: String, value: Any?) = this::class.java.getDeclaredField(name).run {
isAccessible = true
set(this@setDeclaredField, value)
}
fun Any.invokeDeclaredMethod(name: String, vararg args: Pair<Class<*>, Any?>): Any? =
this::class.java.getDeclaredMethod(name, *args.map { (k, _) -> k }.toTypedArray()).run {
isAccessible = true
invoke(this@invokeDeclaredMethod, *args.map { (_, v) -> v }.toTypedArray())
}
fun <T> Class<T>.invokeDeclaredConstructor(vararg args: Pair<Class<*>, Any>): T =
getDeclaredConstructor(*args.map { (k, _) -> k }.toTypedArray()).run {
isAccessible = true
newInstance(*args.map { (_, v) -> v }.toTypedArray())
}
fun <T> Class<*>.getDeclaredClass(name: String): Class<T> =
declaredClasses.first { it.simpleName == name } as Class<T>

View File

@ -43,7 +43,7 @@ class AttendanceSummaryFragment : BaseFragment<AttendanceSummaryFragmentBinding,
private var periodSelection = 0
}
override fun getRefreshScrollingView() = b.list
override fun getRefreshScrollingView() = b.scrollView
private val manager
get() = app.attendanceManager
@ -149,7 +149,6 @@ class AttendanceSummaryFragment : BaseFragment<AttendanceSummaryFragmentBinding,
}
}
@Suppress("SuspendFunctionOnCoroutineScope")
private fun processAttendance(): MutableList<Any> {
val attendance = when (periodSelection) {
0 -> attendance

View File

@ -42,7 +42,7 @@ internal fun BaseFragment<*, *>.setupCanRefresh() {
// keep track of the touch state
when (event.action) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> isTouched = false
MotionEvent.ACTION_DOWN -> isTouched = true
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> isTouched = true
}
// disable refresh when scrolled down
if (view.canScrollVertically(-1))

View File

@ -16,6 +16,8 @@ import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.launch
import pl.szczodrzynski.edziennik.ext.set
import pl.szczodrzynski.edziennik.ext.setDeclaredField
import pl.szczodrzynski.edziennik.ui.base.views.RecyclerTabLayout
abstract class PagerFragment<B : ViewBinding, A : AppCompatActivity>(
inflater: ((inflater: LayoutInflater, parent: ViewGroup?, attachToParent: Boolean) -> B)?,
@ -76,9 +78,15 @@ abstract class PagerFragment<B : ViewBinding, A : AppCompatActivity>(
})
}
TabLayoutMediator(getTabLayout(), getViewPager()) { tab, position ->
tab.text = getPageTitle(position)
}.attach()
when (val tabLayout = getTabLayout()) {
is TabLayout -> TabLayoutMediator(tabLayout, getViewPager()) { tab, position ->
tab.text = getPageTitle(position)
}.attach()
is RecyclerTabLayout -> tabLayout.setupWithViewPager(getViewPager()) { tab, position ->
tab.setDeclaredField("text", getPageTitle(position))
}
}
onPageSelected(savedPageSelection)
}
@ -103,9 +111,9 @@ abstract class PagerFragment<B : ViewBinding, A : AppCompatActivity>(
}
/**
* Called to retrieve the [TabLayout] view of the pager fragment.
* Called to retrieve the [TabLayout] or [RecyclerTabLayout] view of the pager fragment.
*/
abstract fun getTabLayout(): TabLayout
abstract fun getTabLayout(): ViewGroup
/**
* Called to retrieve the [ViewPager2] view of the pager fragment.

View File

@ -0,0 +1,198 @@
/*
* Copyright (c) Kuba Szczodrzyński 2024-7-7.
*/
package pl.szczodrzynski.edziennik.ui.base.views
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.drawable.updateBounds
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import pl.szczodrzynski.edziennik.ext.getDeclaredField
import pl.szczodrzynski.edziennik.ext.invokeDeclaredMethod
import pl.szczodrzynski.edziennik.ext.onClick
import pl.szczodrzynski.edziennik.ext.setDeclaredField
import kotlin.math.min
class RecyclerTabLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : RecyclerView(context, attrs, defStyleAttr) {
private val tabLayout: TabLayout = TabLayout(context, attrs)
private lateinit var linearLayoutManager: LinearLayoutManager
private lateinit var viewPager: ViewPager2
private lateinit var viewPagerAdapter: RecyclerView.Adapter<ViewHolder>
private lateinit var tabAdapter: Adapter
private lateinit var tabConfigurationStrategy: TabConfigurationStrategy
private lateinit var tab: TabLayout.Tab
private lateinit var tabSelectedIndicator: Drawable
private var tabIndicatorGravity: Int = 0
private var tabIndicatorInterpolator: Any? = null
private var selectedTab = 0
fun setupWithViewPager(pager: ViewPager2, strategy: TabConfigurationStrategy) {
linearLayoutManager = LinearLayoutManager(context).also {
it.orientation = HORIZONTAL
}
viewPager = pager
viewPagerAdapter = pager.adapter!!
tabAdapter = Adapter()
tabConfigurationStrategy = strategy
tab = TabLayout.Tab()
tabSelectedIndicator = tabLayout.tabSelectedIndicator
tabIndicatorGravity = tabLayout.tabIndicatorGravity
tabIndicatorInterpolator = tabLayout.getDeclaredField("tabIndicatorInterpolator")
this.addOnScrollListener(OnScrollListener())
viewPager.registerOnPageChangeCallback(OnPageChangeCallback())
viewPagerAdapter.registerAdapterDataObserver(AdapterDataObserver())
setWillNotDraw(false)
setAdapter(tabAdapter)
setHasFixedSize(true)
layoutManager = linearLayoutManager
itemAnimator = null
background = tabLayout.background
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// draw the selected tab indicator
var indicatorHeight = tabSelectedIndicator.bounds.height()
if (indicatorHeight < 0)
indicatorHeight = tabSelectedIndicator.intrinsicHeight
var indicatorTop = 0
var indicatorBottom = 0
when (tabLayout.tabIndicatorGravity) {
TabLayout.INDICATOR_GRAVITY_BOTTOM -> {
indicatorTop = height - indicatorHeight
indicatorBottom = height
}
TabLayout.INDICATOR_GRAVITY_CENTER -> {
indicatorTop = (height - indicatorHeight) / 2
indicatorBottom = (height + indicatorHeight) / 2
}
TabLayout.INDICATOR_GRAVITY_TOP -> {
indicatorTop = 0
indicatorBottom = indicatorHeight
}
TabLayout.INDICATOR_GRAVITY_STRETCH -> {
indicatorTop = 0
indicatorBottom = height
}
}
tabSelectedIndicator.updateBounds(top = indicatorTop, bottom = indicatorBottom)
tabSelectedIndicator.draw(canvas)
}
inner class Adapter : RecyclerView.Adapter<Adapter.TabViewHolder>() {
override fun getItemCount() =
viewPagerAdapter.itemCount
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
TabViewHolder(tabLayout.TabView(context)).also { holder ->
holder.tabView.onClick {
viewPager.setCurrentItem(holder.lastPosition, true)
}
}
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
val tabView = holder.tabView
tabConfigurationStrategy.onConfigureTab(tab, position)
tabView.setDeclaredField("tab", tab)
tabView.invokeDeclaredMethod("updateTab")
tabView.setDeclaredField("tab", null)
tabView.isSelected = position == selectedTab
tabView.isActivated = position == selectedTab
holder.lastPosition = position
}
inner class TabViewHolder(
val tabView: TabLayout.TabView,
var lastPosition: Int = 0,
) : ViewHolder(tabView)
}
inner class OnScrollListener : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val left = tabSelectedIndicator.bounds.left
val right = tabSelectedIndicator.bounds.right
tabSelectedIndicator.updateBounds(left = left - dx, right = right - dx)
}
}
inner class OnPageChangeCallback : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int,
) {
stopScroll()
val thisTab = findViewHolderForLayoutPosition(position)?.itemView
val nextTab = findViewHolderForLayoutPosition(position + 1)?.itemView
// scroll to the currently selected tab
val thisTabWidth = thisTab?.width ?: 0
val nextTabWidth = nextTab?.width ?: 0
val tabDistance = (thisTabWidth / 2) + (nextTabWidth / 2)
val offset = (width / 2.0f) - (thisTabWidth / 2.0f) - (tabDistance * positionOffset)
// 'offset' is the screen position of the tab's left edge
linearLayoutManager.scrollToPositionWithOffset(position, offset.toInt())
// update selection state of the current tab
val roundedPosition = Math.round(position + positionOffset)
if (selectedTab != roundedPosition) {
val previousTab = selectedTab
selectedTab = roundedPosition
tabAdapter.notifyItemRangeChanged(min(previousTab, selectedTab), 2)
}
// update the width and position of the selected tab indicator
tabIndicatorInterpolator?.invokeDeclaredMethod(
name = "updateIndicatorForOffset",
/* tabLayout = */ TabLayout::class.java to tabLayout,
/* startTitle = */ View::class.java to thisTab,
/* endTitle = */ View::class.java to nextTab,
/* offset = */ Float::class.java to positionOffset,
/* indicator = */ Drawable::class.java to tabSelectedIndicator,
)
}
}
inner class AdapterDataObserver : RecyclerView.AdapterDataObserver() {
@SuppressLint("NotifyDataSetChanged")
override fun onChanged() =
tabAdapter.notifyDataSetChanged()
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) =
tabAdapter.notifyItemRangeChanged(positionStart, itemCount)
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) =
tabAdapter.notifyItemRangeInserted(positionStart, itemCount)
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) =
tabAdapter.notifyItemRangeRemoved(positionStart, itemCount)
@SuppressLint("NotifyDataSetChanged")
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) =
tabAdapter.notifyDataSetChanged()
}
}

View File

@ -101,6 +101,8 @@ class TimetableDayFragment : BaseFragment<TimetableDayFragmentBinding, MainActiv
}
private val dayView by dayViewDelegate
override fun getRefreshScrollingView() = b.scrollView
override suspend fun onViewReady(savedInstanceState: Bundle?) {
this.inflater = AsyncLayoutInflater(requireContext())

View File

@ -1,46 +0,0 @@
/*
* Copyright (c) Kuba Szczodrzyński 2019-11-16.
*/
package pl.szczodrzynski.edziennik.utils
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.ScrollView
class ListenerScrollView(
context: Context,
attrs: AttributeSet? = null
) : ScrollView(context, attrs) {
private var onScrollChangedListener: ((v: ListenerScrollView, scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int) -> Unit)? = null
private var onRefreshLayoutEnabledListener: ((enabled: Boolean) -> Unit)? = null
private var refreshLayoutEnabled = true
init {
setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_UP) {
refreshLayoutEnabled = scrollY < 10
onRefreshLayoutEnabledListener?.invoke(refreshLayoutEnabled)
}
false
}
}
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
onScrollChangedListener?.invoke(this, l, t, oldl, oldt)
if (t > 10 && refreshLayoutEnabled) {
refreshLayoutEnabled = false
onRefreshLayoutEnabledListener?.invoke(refreshLayoutEnabled)
}
}
fun setOnScrollChangedListener(l: ((v: ListenerScrollView, scrollX: Int, scrollY: Int, oldScrollX: Int, oldScrollY: Int) -> Unit)?) {
onScrollChangedListener = l
}
fun setOnRefreshLayoutEnabledListener(l: ((enabled: Boolean) -> Unit)?) {
onRefreshLayoutEnabledListener = l
}
}

View File

@ -2,32 +2,27 @@
~ Copyright (c) Kuba Szczodrzyński 2024-7-3.
-->
<LinearLayout
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorSurfaceContainerLow"
app:tabIndicatorColor="?colorPrimary"
app:tabMaxWidth="300dp"
app:tabMinWidth="90dp"
app:tabMode="auto"
app:tabPaddingBottom="12dp"
app:tabPaddingEnd="16dp"
app:tabPaddingStart="16dp"
app:tabPaddingTop="12dp"
app:tabSelectedTextColor="?colorPrimary"
app:tabTextColor="?android:textColorPrimary" />
android:layout_height="wrap_content">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="auto" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -10,28 +10,22 @@
android:id="@+id/timetableLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:visibility="gone">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorSurface">
android:layout_height="wrap_content">
<com.google.android.material.tabs.TabLayout
<pl.szczodrzynski.edziennik.ui.base.views.RecyclerTabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorSurfaceContainerLow"
app:tabIndicatorColor="?colorPrimary"
app:tabMaxWidth="300dp"
app:tabMinWidth="90dp"
app:tabMode="auto"
app:tabPaddingBottom="12dp"
app:tabPaddingBottom="16dp"
app:tabPaddingEnd="16dp"
app:tabPaddingStart="16dp"
app:tabPaddingTop="12dp"
app:tabSelectedTextColor="?colorPrimary"
app:tabTextColor="?colorOnBackground" />
app:tabPaddingTop="16dp" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2

View File

@ -8,18 +8,17 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<pl.szczodrzynski.edziennik.utils.ListenerScrollView
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="visible">
<FrameLayout
android:id="@+id/dayFrame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:layout_height="match_parent">
android:layout_height="wrap_content">
<View
android:id="@+id/timeIndicatorMarker"
@ -37,7 +36,7 @@
android:background="?colorTertiary"
tools:layout_marginTop="100dp" />
</FrameLayout>
</pl.szczodrzynski.edziennik.utils.ListenerScrollView>
</androidx.core.widget.NestedScrollView>
<ProgressBar
android:id="@+id/progressBar"