Skip to content

Commit ca3402b

Browse files
committed
Fix fast scroll, fixes #623
1 parent d3c39aa commit ca3402b

4 files changed

Lines changed: 170 additions & 5 deletions

File tree

presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import android.text.style.BackgroundColorSpan
66
import android.view.View
77
import androidx.annotation.NonNull
88
import androidx.core.content.ContextCompat
9+
import androidx.core.widget.doAfterTextChanged
910
import com.google.android.material.textfield.TextInputEditText
1011
import org.cryptomator.generator.Fragment
1112
import org.cryptomator.presentation.R
1213
import org.cryptomator.presentation.databinding.FragmentTextEditorBinding
1314
import org.cryptomator.presentation.presenter.TextEditorPresenter
15+
import org.cryptomator.presentation.ui.layout.applySystemBarsMargins
1416
import org.cryptomator.presentation.ui.layout.applySystemBarsPadding
17+
import org.cryptomator.presentation.ui.layout.attachFastScrollThumb
1518
import javax.inject.Inject
1619

1720
@Fragment
@@ -20,6 +23,8 @@ class TextEditorFragment : BaseFragment<FragmentTextEditorBinding>(FragmentTextE
2023
@Inject
2124
lateinit var textEditorPresenter: TextEditorPresenter
2225

26+
private var fastScrollCleanup: (() -> Unit)? = null
27+
2328
val textFileContent: String
2429
get() = binding.textEditor.text.toString()
2530

@@ -103,7 +108,7 @@ class TextEditorFragment : BaseFragment<FragmentTextEditorBinding>(FragmentTextE
103108
textEditorPresenter.lastFilterLocation = index
104109

105110
binding.textEditor.setSelection(index, index + it.length)
106-
binding.textEditor.post { binding.textEditor.bringPointIntoView(index) }
111+
binding.textEditor.post { scrollCaretIntoView() }
107112
}
108113
}
109114

@@ -117,7 +122,37 @@ class TextEditorFragment : BaseFragment<FragmentTextEditorBinding>(FragmentTextE
117122

118123
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
119124
super.onViewCreated(view, savedInstanceState)
120-
binding.textEditor.applySystemBarsPadding(left = true, right = true, bottom = true)
125+
binding.textViewWrapper.applySystemBarsPadding(left = true, right = true, bottom = true)
126+
binding.scrollThumb.applySystemBarsMargins(end = true, bottom = true)
127+
binding.scrollTrack.applySystemBarsMargins(end = true, bottom = true)
128+
fastScrollCleanup = binding.textViewWrapper.attachFastScrollThumb(binding.scrollThumb, binding.scrollTrack, binding.textEditor)
129+
setupCaretAutoScroll()
130+
}
131+
132+
override fun onDestroyView() {
133+
fastScrollCleanup?.invoke()
134+
fastScrollCleanup = null
135+
super.onDestroyView()
136+
}
137+
138+
private fun setupCaretAutoScroll() {
139+
binding.textEditor.doAfterTextChanged {
140+
binding.textEditor.post { scrollCaretIntoView() }
141+
}
142+
}
143+
144+
private fun scrollCaretIntoView() {
145+
val editor = binding.textEditor
146+
val scroll = binding.textViewWrapper
147+
val layout = editor.layout ?: return
148+
val line = layout.getLineForOffset(editor.selectionEnd)
149+
val lineTop = layout.getLineTop(line)
150+
val lineBottom = layout.getLineBottom(line)
151+
val visibleHeight = scroll.height - scroll.paddingTop - scroll.paddingBottom
152+
when {
153+
lineTop < scroll.scrollY -> scroll.smoothScrollTo(0, lineTop)
154+
lineBottom > scroll.scrollY + visibleHeight -> scroll.smoothScrollTo(0, lineBottom - visibleHeight)
155+
}
121156
}
122157

123158
enum class Direction { PREVIOUS, NEXT }
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package org.cryptomator.presentation.ui.layout
2+
3+
import android.view.MotionEvent
4+
import android.view.View
5+
import android.view.ViewGroup
6+
import android.view.ViewTreeObserver
7+
import android.widget.ScrollView
8+
9+
/**
10+
* Wires [thumb] as a draggable fast-scroll handle over this [ScrollView] (whose scrolling content is [content]).
11+
* Tapping anywhere on [track] jumps the thumb to that position.
12+
* Returns a cleanup callback to be invoked from the host's `onDestroyView`.
13+
*/
14+
fun ScrollView.attachFastScrollThumb(thumb: View, track: View, content: View): () -> Unit {
15+
val scroll = this
16+
17+
fun scrollableHeight(): Int =
18+
(content.height + scroll.paddingTop + scroll.paddingBottom - scroll.height).coerceAtLeast(0)
19+
20+
fun trackHeight(): Int {
21+
val bottomMargin = (thumb.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 0
22+
return (scroll.height - thumb.height - bottomMargin).coerceAtLeast(0)
23+
}
24+
25+
fun syncThumb() {
26+
val total = scrollableHeight()
27+
if (total == 0) {
28+
thumb.visibility = View.GONE
29+
track.visibility = View.GONE
30+
return
31+
}
32+
thumb.visibility = View.VISIBLE
33+
track.visibility = View.VISIBLE
34+
thumb.translationY = scroll.scrollY.toFloat() / total * trackHeight()
35+
}
36+
37+
fun jumpToTrackY(yOnTrack: Float) {
38+
val trackPx = trackHeight().toFloat()
39+
if (trackPx == 0f) return
40+
val clamped = yOnTrack.coerceIn(0f, trackPx)
41+
scroll.scrollTo(0, (clamped / trackPx * scrollableHeight()).toInt())
42+
}
43+
44+
val scrollListener = ViewTreeObserver.OnScrollChangedListener { syncThumb() }
45+
scroll.viewTreeObserver.addOnScrollChangedListener(scrollListener)
46+
47+
val layoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> syncThumb() }
48+
scroll.addOnLayoutChangeListener(layoutListener)
49+
content.addOnLayoutChangeListener(layoutListener)
50+
51+
var thumbDragOffsetY = 0f
52+
var thumbDragMoved = false
53+
thumb.isClickable = true
54+
thumb.setOnTouchListener { _, event ->
55+
when (event.actionMasked) {
56+
MotionEvent.ACTION_DOWN -> {
57+
thumbDragOffsetY = event.rawY - thumb.translationY
58+
thumbDragMoved = false
59+
thumb.isPressed = true
60+
true
61+
}
62+
MotionEvent.ACTION_MOVE -> {
63+
thumbDragMoved = true
64+
jumpToTrackY(event.rawY - thumbDragOffsetY)
65+
true
66+
}
67+
MotionEvent.ACTION_UP -> {
68+
thumb.isPressed = false
69+
if (!thumbDragMoved) thumb.performClick()
70+
true
71+
}
72+
MotionEvent.ACTION_CANCEL -> {
73+
thumb.isPressed = false
74+
true
75+
}
76+
else -> false
77+
}
78+
}
79+
80+
track.isClickable = true
81+
track.setOnTouchListener { _, event ->
82+
when (event.actionMasked) {
83+
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
84+
jumpToTrackY(event.y - thumb.height / 2f)
85+
true
86+
}
87+
MotionEvent.ACTION_UP -> {
88+
track.performClick()
89+
true
90+
}
91+
else -> false
92+
}
93+
}
94+
95+
return {
96+
scroll.viewTreeObserver.removeOnScrollChangedListener(scrollListener)
97+
scroll.removeOnLayoutChangeListener(layoutListener)
98+
content.removeOnLayoutChangeListener(layoutListener)
99+
thumb.setOnTouchListener(null)
100+
track.setOnTouchListener(null)
101+
}
102+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:shape="rectangle">
4+
<corners android:radius="3dp" />
5+
<solid android:color="@color/colorPrimaryTransparent" />
6+
</shape>

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,36 @@
44
android:layout_width="match_parent"
55
android:layout_height="match_parent">
66

7+
<ScrollView
8+
android:id="@+id/text_view_wrapper"
9+
android:layout_width="match_parent"
10+
android:layout_height="match_parent"
11+
android:clipToPadding="false"
12+
android:scrollbars="none">
13+
714
<com.google.android.material.textfield.TextInputEditText
815
android:id="@+id/text_editor"
916
android:layout_width="match_parent"
1017
android:layout_height="wrap_content"
1118
android:gravity="top"
1219
android:imeOptions="flagNoPersonalizedLearning"
13-
android:inputType="textMultiLine"
14-
android:scrollbars="vertical"
15-
android:overScrollMode="ifContentScrolls" />
20+
android:inputType="textMultiLine" />
21+
</ScrollView>
22+
23+
<View
24+
android:id="@+id/scroll_track"
25+
android:layout_width="24dp"
26+
android:layout_height="match_parent"
27+
android:layout_gravity="end"
28+
android:visibility="gone" />
29+
30+
<View
31+
android:id="@+id/scroll_thumb"
32+
android:layout_width="6dp"
33+
android:layout_height="48dp"
34+
android:layout_gravity="end"
35+
android:layout_marginEnd="2dp"
36+
android:background="@drawable/scroll_thumb"
37+
android:visibility="gone" />
1638

1739
</androidx.coordinatorlayout.widget.CoordinatorLayout>

0 commit comments

Comments
 (0)