Skip to content

Commit fc1dcc2

Browse files
Shubham-344danascape
authored andcommitted
feat: Implement custom fastscroll on homeview of messages
PR: #144 Signed-off-by: Saalim Quadri <danascape@gmail.com>
1 parent 0a404c1 commit fc1dcc2

7 files changed

Lines changed: 284 additions & 1 deletion

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*
2+
* Copyright (C) 2025 Saalim Quadri <danascape@gmail.com>
3+
*/
4+
5+
package com.moez.QKSMS.common.widget
6+
7+
import android.content.Context
8+
import android.util.AttributeSet
9+
import android.view.LayoutInflater
10+
import android.view.MotionEvent
11+
import android.view.View
12+
import android.widget.FrameLayout
13+
import android.widget.TextView
14+
import androidx.recyclerview.widget.LinearLayoutManager
15+
import androidx.recyclerview.widget.RecyclerView
16+
import org.prauga.messages.R
17+
import kotlin.math.abs
18+
import kotlin.math.max
19+
import kotlin.math.min
20+
21+
class FastScrollerView @JvmOverloads constructor(
22+
context: Context,
23+
attrs: AttributeSet? = null,
24+
defStyleAttr: Int = 0
25+
) : FrameLayout(context, attrs, defStyleAttr) {
26+
27+
interface SectionTitleProvider {
28+
fun getSectionTitle(position: Int): String
29+
}
30+
31+
private var recyclerView: RecyclerView? = null
32+
private var thumb: View
33+
private var popup: TextView
34+
35+
private var isDragging = false
36+
private var hidePopupRunnable = Runnable { popup.visibility = View.GONE }
37+
38+
// --- OPTIMIZATION CACHES ---
39+
private var lastTargetPos = -1
40+
private var lastOffset = -1
41+
private var lastPopupPos = -1
42+
private var cachedItemHeight = 0
43+
private var cachedMaxTargetPos = 0
44+
45+
init {
46+
LayoutInflater.from(context).inflate(R.layout.fast_scroller_view, this, true)
47+
thumb = findViewById(R.id.fast_scroller_thumb)
48+
popup = findViewById(R.id.fast_scroller_popup)
49+
popup.visibility = View.GONE
50+
}
51+
52+
fun setupWithRecyclerView(recyclerView: RecyclerView) {
53+
this.recyclerView = recyclerView
54+
55+
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
56+
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
57+
if (!isDragging) {
58+
updateThumbPosition()
59+
}
60+
}
61+
})
62+
}
63+
64+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
65+
super.onSizeChanged(w, h, oldw, oldh)
66+
updateThumbPosition()
67+
}
68+
69+
private fun updateThumbPosition() {
70+
val rv = recyclerView ?: return
71+
val extent = rv.computeVerticalScrollExtent()
72+
val range = rv.computeVerticalScrollRange()
73+
val offset = rv.computeVerticalScrollOffset()
74+
75+
if (range <= extent || range == 0) {
76+
thumb.visibility = View.GONE
77+
return
78+
}
79+
80+
thumb.visibility = View.VISIBLE
81+
val proportion = offset.toFloat() / (range - extent).toFloat()
82+
val maxThumbY = height - thumb.height
83+
val safeProportion = if (proportion.isNaN()) 0f else min(max(0f, proportion), 1f)
84+
thumb.y = safeProportion * maxThumbY
85+
}
86+
87+
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
88+
if (ev.action == MotionEvent.ACTION_DOWN) {
89+
val touchSlop = width - (48 * resources.displayMetrics.density)
90+
if (ev.x >= touchSlop && thumb.visibility == View.VISIBLE) {
91+
return true
92+
}
93+
}
94+
return super.onInterceptTouchEvent(ev)
95+
}
96+
97+
override fun onTouchEvent(event: MotionEvent): Boolean {
98+
val rv = recyclerView ?: return super.onTouchEvent(event)
99+
100+
when (event.action) {
101+
MotionEvent.ACTION_DOWN -> {
102+
val touchSlop = width - (48 * resources.displayMetrics.density)
103+
if (event.x >= touchSlop) {
104+
isDragging = true
105+
thumb.isPressed = true
106+
removeCallbacks(hidePopupRunnable)
107+
108+
// Pre-calculate expensive math once when dragging starts
109+
prepareScrollMath(rv)
110+
updateScrollAndPopup(event.y)
111+
return true
112+
}
113+
}
114+
MotionEvent.ACTION_MOVE -> {
115+
if (isDragging) {
116+
updateScrollAndPopup(event.y)
117+
return true
118+
}
119+
}
120+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
121+
if (isDragging) {
122+
isDragging = false
123+
thumb.isPressed = false
124+
postDelayed(hidePopupRunnable, 500)
125+
126+
// Reset caches
127+
lastTargetPos = -1
128+
lastOffset = -1
129+
lastPopupPos = -1
130+
return true
131+
}
132+
}
133+
}
134+
return super.onTouchEvent(event)
135+
}
136+
137+
private fun prepareScrollMath(rv: RecyclerView) {
138+
val adapter = rv.adapter ?: return
139+
val itemCount = adapter.itemCount
140+
141+
// Grab height from the first visible view safely
142+
val firstView = rv.getChildAt(0)
143+
cachedItemHeight = firstView?.height?.takeIf { it > 0 } ?: (72 * resources.displayMetrics.density).toInt()
144+
145+
val visibleItemCount = max(1, rv.height / cachedItemHeight)
146+
cachedMaxTargetPos = max(0, itemCount - visibleItemCount)
147+
}
148+
149+
private fun updateScrollAndPopup(y: Float) {
150+
val rv = recyclerView ?: return
151+
val layoutManager = rv.layoutManager as? LinearLayoutManager ?: return
152+
val adapter = rv.adapter ?: return
153+
if (cachedMaxTargetPos <= 0) return
154+
155+
// 1. Move the thumb visually
156+
val maxThumbY = height - thumb.height
157+
val newY = min(max(0f, y - thumb.height / 2f), maxThumbY.toFloat())
158+
thumb.y = newY
159+
val proportion = newY / maxThumbY
160+
161+
// 2. Calculate smooth position
162+
val exactPosition = proportion * cachedMaxTargetPos
163+
val targetPos = exactPosition.toInt().coerceIn(0, cachedMaxTargetPos)
164+
val subItemFraction = exactPosition - targetPos
165+
val offset = -(subItemFraction * cachedItemHeight).toInt()
166+
167+
// 3. ONLY trigger a layout pass if the position or offset actually changed
168+
// This prevents the UI thread from being spammed with redundant draw calls
169+
if (targetPos != lastTargetPos || abs(offset - lastOffset) > 1) {
170+
layoutManager.scrollToPositionWithOffset(targetPos, offset)
171+
lastTargetPos = targetPos
172+
lastOffset = offset
173+
}
174+
175+
// 4. Update Date Modal Y-position constantly so it tracks the thumb perfectly
176+
val maxPopupY = height - popup.height
177+
popup.y = min(max(0f, newY + thumb.height / 2f - popup.height / 2f), maxPopupY.toFloat())
178+
179+
// 5. ONLY re-format the date text if we crossed into a new item
180+
if (targetPos != lastPopupPos && adapter is SectionTitleProvider) {
181+
val title = adapter.getSectionTitle(targetPos)
182+
if (title.isNotEmpty()) {
183+
popup.text = title
184+
if (popup.visibility != View.VISIBLE) popup.visibility = View.VISIBLE
185+
} else {
186+
popup.visibility = View.GONE
187+
}
188+
lastPopupPos = targetPos
189+
}
190+
}
191+
}

presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import android.view.ViewGroup
2525
import androidx.core.text.buildSpannedString
2626
import androidx.core.view.isVisible
2727
import androidx.recyclerview.widget.RecyclerView
28+
import com.moez.QKSMS.common.widget.FastScrollerView
2829
import org.prauga.messages.R
2930
import org.prauga.messages.common.Navigator
3031
import org.prauga.messages.common.base.QkRealmAdapter
@@ -47,7 +48,7 @@ class ConversationsAdapter @Inject constructor(
4748
private val scheduledMessageRepo: ScheduledMessageRepository,
4849
private val navigator: Navigator,
4950
private val phoneNumberUtils: PhoneNumberUtils
50-
) : QkRealmAdapter<Conversation, QkViewHolder>() {
51+
) : QkRealmAdapter<Conversation, QkViewHolder>(), FastScrollerView.SectionTitleProvider {
5152
private val disposables = CompositeDisposable()
5253

5354
init {
@@ -159,4 +160,14 @@ class ConversationsAdapter @Inject constructor(
159160
}
160161
}
161162

163+
override fun getSectionTitle(position: Int): String {
164+
val conversation = getItem(position) ?: return ""
165+
val timestamp = conversation.date
166+
return if (timestamp > 0) {
167+
dateFormatter.getConversationTimestamp(timestamp) ?: ""
168+
} else {
169+
""
170+
}
171+
}
172+
162173
}

presentation/src/main/java/com/moez/QKSMS/feature/main/MainActivity.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,8 @@ class MainActivity : QkThemedActivity<MainActivityBinding>(MainActivityBinding::
215215
navigator.showConversation(conversationId, messageId)
216216
}
217217

218+
binding.fastScroller.setupWithRecyclerView(binding.recyclerView)
219+
218220
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
219221
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
220222
super.onScrolled(recyclerView, dx, dy)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright (C) 2025 Saalim Quadri <danascape@gmail.com>
4+
-->
5+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
6+
android:shape="rectangle">
7+
<corners
8+
android:topLeftRadius="24dp"
9+
android:topRightRadius="24dp"
10+
android:bottomLeftRadius="24dp"
11+
android:bottomRightRadius="4dp" />
12+
<solid android:color="?android:attr/colorAccent" />
13+
</shape>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright (C) 2025 Saalim Quadri <danascape@gmail.com>
4+
-->
5+
<selector xmlns:android="http://schemas.android.com/apk/res/android">
6+
<item android:state_pressed="true">
7+
<shape android:shape="rectangle">
8+
<corners android:radius="4dp" />
9+
<solid android:color="?android:attr/colorAccent" />
10+
<size android:width="8dp" android:height="48dp" />
11+
</shape>
12+
</item>
13+
<item>
14+
<shape android:shape="rectangle">
15+
<corners android:radius="4dp" />
16+
<solid android:color="?android:attr/textColorTertiary" />
17+
<size android:width="8dp" android:height="48dp" />
18+
</shape>
19+
</item>
20+
</selector>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright (C) 2025 Saalim Quadri <danascape@gmail.com>
4+
-->
5+
<merge xmlns:android="http://schemas.android.com/apk/res/android"
6+
xmlns:tools="http://schemas.android.com/tools"
7+
android:layout_width="match_parent"
8+
android:layout_height="match_parent">
9+
10+
<TextView
11+
android:id="@+id/fast_scroller_popup"
12+
android:layout_width="wrap_content"
13+
android:layout_height="wrap_content"
14+
android:layout_gravity="end"
15+
android:layout_marginEnd="24dp"
16+
android:background="@drawable/fast_scroller_popup_bg"
17+
android:gravity="center"
18+
android:minHeight="48dp"
19+
android:paddingStart="16dp"
20+
android:paddingEnd="16dp"
21+
android:textColor="@android:color/white"
22+
android:textSize="14sp"
23+
android:textStyle="bold"
24+
tools:text="Yesterday" />
25+
26+
<View
27+
android:id="@+id/fast_scroller_thumb"
28+
android:layout_width="8dp"
29+
android:layout_height="48dp"
30+
android:layout_gravity="end"
31+
android:layout_marginEnd="4dp"
32+
android:background="@drawable/fast_scroller_thumb_selector" />
33+
34+
</merge>

presentation/src/main/res/layout/main_activity.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,18 @@
256256
app:layout_constraintTop_toBottomOf="@id/filterGroup"
257257
tools:listitem="@layout/conversation_list_item" />
258258

259+
<com.moez.QKSMS.common.widget.FastScrollerView
260+
android:id="@+id/fastScroller"
261+
android:layout_width="0dp"
262+
android:layout_height="0dp"
263+
android:elevation="2dp"
264+
android:layout_marginBottom="16dp"
265+
android:layout_marginRight="3dp"
266+
app:layout_constraintBottom_toTopOf="@id/compose"
267+
app:layout_constraintEnd_toEndOf="@id/recyclerView"
268+
app:layout_constraintStart_toStartOf="@id/recyclerView"
269+
app:layout_constraintTop_toTopOf="@id/recyclerView" />
270+
259271
<com.google.android.material.card.MaterialCardView
260272
android:id="@+id/searchPill"
261273
android:layout_width="0dp"

0 commit comments

Comments
 (0)