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..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,16 +2,20 @@ 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 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 +24,9 @@ class TextEditorFragment : BaseFragment(FragmentTextE @Inject lateinit var textEditorPresenter: TextEditorPresenter + private var fastScrollCleanup: (() -> Unit)? = null + private var caretAutoScrollWatcher: TextWatcher? = null + val textFileContent: String get() = binding.textEditor.text.toString() @@ -103,7 +110,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 +124,39 @@ 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 + caretAutoScrollWatcher?.let { binding.textEditor.removeTextChangedListener(it) } + caretAutoScrollWatcher = null + super.onDestroyView() + } + + private fun setupCaretAutoScroll() { + caretAutoScrollWatcher = 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 = 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) + 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" /> + + + + +