From ca3402baa75f69d1ae36bd685c255d103fd19bd7 Mon Sep 17 00:00:00 2001 From: Julian Raufelder Date: Sat, 25 Apr 2026 22:09:42 +0200 Subject: [PATCH 1/3] Fix fast scroll, fixes #623 --- .../ui/fragment/TextEditorFragment.kt | 39 ++++++- .../ui/layout/FastScrollHelper.kt | 102 ++++++++++++++++++ .../src/main/res/drawable/scroll_thumb.xml | 6 ++ .../main/res/layout/fragment_text_editor.xml | 28 ++++- 4 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 presentation/src/main/java/org/cryptomator/presentation/ui/layout/FastScrollHelper.kt create mode 100644 presentation/src/main/res/drawable/scroll_thumb.xml diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt index 00ee27626..92f0fa380 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt @@ -6,12 +6,15 @@ import android.text.style.BackgroundColorSpan import android.view.View import androidx.annotation.NonNull import androidx.core.content.ContextCompat +import androidx.core.widget.doAfterTextChanged import com.google.android.material.textfield.TextInputEditText import org.cryptomator.generator.Fragment import org.cryptomator.presentation.R import org.cryptomator.presentation.databinding.FragmentTextEditorBinding import org.cryptomator.presentation.presenter.TextEditorPresenter +import org.cryptomator.presentation.ui.layout.applySystemBarsMargins import org.cryptomator.presentation.ui.layout.applySystemBarsPadding +import org.cryptomator.presentation.ui.layout.attachFastScrollThumb import javax.inject.Inject @Fragment @@ -20,6 +23,8 @@ class TextEditorFragment : BaseFragment(FragmentTextE @Inject lateinit var textEditorPresenter: TextEditorPresenter + private var fastScrollCleanup: (() -> Unit)? = null + val textFileContent: String get() = binding.textEditor.text.toString() @@ -103,7 +108,7 @@ class TextEditorFragment : BaseFragment(FragmentTextE textEditorPresenter.lastFilterLocation = index binding.textEditor.setSelection(index, index + it.length) - binding.textEditor.post { binding.textEditor.bringPointIntoView(index) } + binding.textEditor.post { scrollCaretIntoView() } } } @@ -117,7 +122,37 @@ class TextEditorFragment : BaseFragment(FragmentTextE override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.textEditor.applySystemBarsPadding(left = true, right = true, bottom = true) + binding.textViewWrapper.applySystemBarsPadding(left = true, right = true, bottom = true) + binding.scrollThumb.applySystemBarsMargins(end = true, bottom = true) + binding.scrollTrack.applySystemBarsMargins(end = true, bottom = true) + fastScrollCleanup = binding.textViewWrapper.attachFastScrollThumb(binding.scrollThumb, binding.scrollTrack, binding.textEditor) + setupCaretAutoScroll() + } + + override fun onDestroyView() { + fastScrollCleanup?.invoke() + fastScrollCleanup = null + super.onDestroyView() + } + + private fun setupCaretAutoScroll() { + binding.textEditor.doAfterTextChanged { + binding.textEditor.post { scrollCaretIntoView() } + } + } + + private fun scrollCaretIntoView() { + val editor = binding.textEditor + val scroll = binding.textViewWrapper + val layout = editor.layout ?: return + val line = layout.getLineForOffset(editor.selectionEnd) + val lineTop = layout.getLineTop(line) + val lineBottom = layout.getLineBottom(line) + val visibleHeight = scroll.height - scroll.paddingTop - scroll.paddingBottom + when { + lineTop < scroll.scrollY -> scroll.smoothScrollTo(0, lineTop) + lineBottom > scroll.scrollY + visibleHeight -> scroll.smoothScrollTo(0, lineBottom - visibleHeight) + } } enum class Direction { PREVIOUS, NEXT } diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/layout/FastScrollHelper.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/layout/FastScrollHelper.kt new file mode 100644 index 000000000..7c3d0261a --- /dev/null +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/layout/FastScrollHelper.kt @@ -0,0 +1,102 @@ +package org.cryptomator.presentation.ui.layout + +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.ScrollView + +/** + * Wires [thumb] as a draggable fast-scroll handle over this [ScrollView] (whose scrolling content is [content]). + * Tapping anywhere on [track] jumps the thumb to that position. + * Returns a cleanup callback to be invoked from the host's `onDestroyView`. + */ +fun ScrollView.attachFastScrollThumb(thumb: View, track: View, content: View): () -> Unit { + val scroll = this + + fun scrollableHeight(): Int = + (content.height + scroll.paddingTop + scroll.paddingBottom - scroll.height).coerceAtLeast(0) + + fun trackHeight(): Int { + val bottomMargin = (thumb.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 0 + return (scroll.height - thumb.height - bottomMargin).coerceAtLeast(0) + } + + fun syncThumb() { + val total = scrollableHeight() + if (total == 0) { + thumb.visibility = View.GONE + track.visibility = View.GONE + return + } + thumb.visibility = View.VISIBLE + track.visibility = View.VISIBLE + thumb.translationY = scroll.scrollY.toFloat() / total * trackHeight() + } + + fun jumpToTrackY(yOnTrack: Float) { + val trackPx = trackHeight().toFloat() + if (trackPx == 0f) return + val clamped = yOnTrack.coerceIn(0f, trackPx) + scroll.scrollTo(0, (clamped / trackPx * scrollableHeight()).toInt()) + } + + val scrollListener = ViewTreeObserver.OnScrollChangedListener { syncThumb() } + scroll.viewTreeObserver.addOnScrollChangedListener(scrollListener) + + val layoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> syncThumb() } + scroll.addOnLayoutChangeListener(layoutListener) + content.addOnLayoutChangeListener(layoutListener) + + var thumbDragOffsetY = 0f + var thumbDragMoved = false + thumb.isClickable = true + thumb.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + thumbDragOffsetY = event.rawY - thumb.translationY + thumbDragMoved = false + thumb.isPressed = true + true + } + MotionEvent.ACTION_MOVE -> { + thumbDragMoved = true + jumpToTrackY(event.rawY - thumbDragOffsetY) + true + } + MotionEvent.ACTION_UP -> { + thumb.isPressed = false + if (!thumbDragMoved) thumb.performClick() + true + } + MotionEvent.ACTION_CANCEL -> { + thumb.isPressed = false + true + } + else -> false + } + } + + track.isClickable = true + track.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + jumpToTrackY(event.y - thumb.height / 2f) + true + } + MotionEvent.ACTION_UP -> { + track.performClick() + true + } + else -> false + } + } + + return { + scroll.viewTreeObserver.removeOnScrollChangedListener(scrollListener) + scroll.removeOnLayoutChangeListener(layoutListener) + content.removeOnLayoutChangeListener(layoutListener) + thumb.setOnTouchListener(null) + track.setOnTouchListener(null) + } +} diff --git a/presentation/src/main/res/drawable/scroll_thumb.xml b/presentation/src/main/res/drawable/scroll_thumb.xml new file mode 100644 index 000000000..adcfab3c6 --- /dev/null +++ b/presentation/src/main/res/drawable/scroll_thumb.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/presentation/src/main/res/layout/fragment_text_editor.xml b/presentation/src/main/res/layout/fragment_text_editor.xml index 6d40fadf8..157bc4b09 100644 --- a/presentation/src/main/res/layout/fragment_text_editor.xml +++ b/presentation/src/main/res/layout/fragment_text_editor.xml @@ -4,14 +4,36 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + android:inputType="textMultiLine" /> + + + + + From 7e6289f47c157025b4e7e1501325b22ce909ec0a Mon Sep 17 00:00:00 2001 From: Julian Raufelder Date: Sat, 25 Apr 2026 22:29:15 +0200 Subject: [PATCH 2/3] Apply suggestions from review --- .../presentation/ui/fragment/TextEditorFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt index 92f0fa380..3fb795c16 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt @@ -146,8 +146,8 @@ class TextEditorFragment : BaseFragment(FragmentTextE val scroll = binding.textViewWrapper val layout = editor.layout ?: return val line = layout.getLineForOffset(editor.selectionEnd) - val lineTop = layout.getLineTop(line) - val lineBottom = layout.getLineBottom(line) + val lineTop = editor.paddingTop + layout.getLineTop(line) + val lineBottom = editor.paddingTop + layout.getLineBottom(line) val visibleHeight = scroll.height - scroll.paddingTop - scroll.paddingBottom when { lineTop < scroll.scrollY -> scroll.smoothScrollTo(0, lineTop) From eaa2eb252ae15f1e5fe51f04d50f5e8297ee2cc9 Mon Sep 17 00:00:00 2001 From: Julian Raufelder Date: Sun, 26 Apr 2026 10:15:36 +0200 Subject: [PATCH 3/3] Apply suggestions from review --- .../presentation/ui/fragment/TextEditorFragment.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt index 3fb795c16..88ea0f8a5 100644 --- a/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt +++ b/presentation/src/main/java/org/cryptomator/presentation/ui/fragment/TextEditorFragment.kt @@ -2,6 +2,7 @@ package org.cryptomator.presentation.ui.fragment import android.os.Bundle import android.text.Spannable +import android.text.TextWatcher import android.text.style.BackgroundColorSpan import android.view.View import androidx.annotation.NonNull @@ -24,6 +25,7 @@ class TextEditorFragment : BaseFragment(FragmentTextE lateinit var textEditorPresenter: TextEditorPresenter private var fastScrollCleanup: (() -> Unit)? = null + private var caretAutoScrollWatcher: TextWatcher? = null val textFileContent: String get() = binding.textEditor.text.toString() @@ -132,11 +134,13 @@ class TextEditorFragment : BaseFragment(FragmentTextE override fun onDestroyView() { fastScrollCleanup?.invoke() fastScrollCleanup = null + caretAutoScrollWatcher?.let { binding.textEditor.removeTextChangedListener(it) } + caretAutoScrollWatcher = null super.onDestroyView() } private fun setupCaretAutoScroll() { - binding.textEditor.doAfterTextChanged { + caretAutoScrollWatcher = binding.textEditor.doAfterTextChanged { binding.textEditor.post { scrollCaretIntoView() } } }