From f10bc42c7b5d6714f24435cbd9b871cd61a1b9a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20Szczodrzy=C5=84ski?= Date: Sun, 7 Jul 2024 22:18:00 +0200 Subject: [PATCH] [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 --- app/proguard-rules.pro | 12 ++ .../edziennik/ext/ReflectionExtensions.kt | 32 +++ .../attendance/AttendanceSummaryFragment.kt | 3 +- .../ui/base/fragment/ActivityUtil.kt | 2 +- .../ui/base/fragment/PagerFragment.kt | 18 +- .../ui/base/views/RecyclerTabLayout.kt | 198 ++++++++++++++++++ .../ui/timetable/TimetableDayFragment.kt | 2 + .../edziennik/utils/ListenerScrollView.kt | 46 ---- .../main/res/layout/base_pager_fragment.xml | 27 +-- .../main/res/layout/fragment_timetable_v2.xml | 16 +- .../res/layout/timetable_day_fragment.xml | 9 +- 11 files changed, 279 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/ext/ReflectionExtensions.kt create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/ui/base/views/RecyclerTabLayout.kt delete mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/utils/ListenerScrollView.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index c4b81a88..8d6de00c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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$* { ; diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ext/ReflectionExtensions.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ext/ReflectionExtensions.kt new file mode 100644 index 00000000..3ced4e54 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ext/ReflectionExtensions.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2024-7-6. + */ + +@file:Suppress("UNCHECKED_CAST") + +package pl.szczodrzynski.edziennik.ext + +fun 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, Any?>): Any? = + this::class.java.getDeclaredMethod(name, *args.map { (k, _) -> k }.toTypedArray()).run { + isAccessible = true + invoke(this@invokeDeclaredMethod, *args.map { (_, v) -> v }.toTypedArray()) + } + +fun Class.invokeDeclaredConstructor(vararg args: Pair, Any>): T = + getDeclaredConstructor(*args.map { (k, _) -> k }.toTypedArray()).run { + isAccessible = true + newInstance(*args.map { (_, v) -> v }.toTypedArray()) + } + +fun Class<*>.getDeclaredClass(name: String): Class = + declaredClasses.first { it.simpleName == name } as Class diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/attendance/AttendanceSummaryFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/attendance/AttendanceSummaryFragment.kt index 60e93d5d..3fe0413a 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/attendance/AttendanceSummaryFragment.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/attendance/AttendanceSummaryFragment.kt @@ -43,7 +43,7 @@ class AttendanceSummaryFragment : BaseFragment { val attendance = when (periodSelection) { 0 -> attendance diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/fragment/ActivityUtil.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/fragment/ActivityUtil.kt index 4f3aa776..818b5ca4 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/fragment/ActivityUtil.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/fragment/ActivityUtil.kt @@ -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)) diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/fragment/PagerFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/fragment/PagerFragment.kt index 83d64e8f..0f43229d 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/fragment/PagerFragment.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/fragment/PagerFragment.kt @@ -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( inflater: ((inflater: LayoutInflater, parent: ViewGroup?, attachToParent: Boolean) -> B)?, @@ -76,9 +78,15 @@ abstract class PagerFragment( }) } - 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( } /** - * 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. diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/views/RecyclerTabLayout.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/views/RecyclerTabLayout.kt new file mode 100644 index 00000000..f9d63b66 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/base/views/RecyclerTabLayout.kt @@ -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 + 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() { + 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() + } +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/timetable/TimetableDayFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/timetable/TimetableDayFragment.kt index 4b412a02..6e300e20 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/timetable/TimetableDayFragment.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/timetable/TimetableDayFragment.kt @@ -101,6 +101,8 @@ class TimetableDayFragment : BaseFragment 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 - } -} \ No newline at end of file diff --git a/app/src/main/res/layout/base_pager_fragment.xml b/app/src/main/res/layout/base_pager_fragment.xml index 7456ae50..bd6e4ce7 100644 --- a/app/src/main/res/layout/base_pager_fragment.xml +++ b/app/src/main/res/layout/base_pager_fragment.xml @@ -2,32 +2,27 @@ ~ Copyright (c) Kuba Szczodrzyński 2024-7-3. --> - - + android:layout_height="wrap_content"> + + + - + diff --git a/app/src/main/res/layout/fragment_timetable_v2.xml b/app/src/main/res/layout/fragment_timetable_v2.xml index c535a724..a82554f2 100644 --- a/app/src/main/res/layout/fragment_timetable_v2.xml +++ b/app/src/main/res/layout/fragment_timetable_v2.xml @@ -10,28 +10,22 @@ android:id="@+id/timetableLayout" android:layout_width="match_parent" android:layout_height="match_parent" + android:orientation="vertical" tools:visibility="gone"> + android:layout_height="wrap_content"> - + app:tabPaddingTop="16dp" /> - + android:layout_height="wrap_content"> - +