mirror of
https://github.com/szkolny-eu/szkolny-android.git
synced 2025-01-18 04:46:44 -06:00
[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:
parent
6371d71b7a
commit
f10bc42c7b
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@ -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>;
|
||||
|
@ -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>
|
@ -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
|
||||
|
@ -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))
|
||||
|
@ -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.
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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())
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user