From 1dd39b70dc4dad5fb43316daf8989403425682f3 Mon Sep 17 00:00:00 2001 From: huyennbl <33422297+huyennbl@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:07:57 +0700 Subject: [PATCH 1/3] feat: draggable sidebar --- .../contacts/fragments/MyViewPagerFragment.kt | 95 +++++++++++++++++++ .../org/fossify/contacts/helpers/Config.kt | 3 + .../org/fossify/contacts/helpers/Constants.kt | 1 + app/src/main/res/drawable/ic_dot.xml | 10 ++ .../res/layout/fragment_letters_layout.xml | 37 ++++++-- 5 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 app/src/main/res/drawable/ic_dot.xml diff --git a/app/src/main/kotlin/org/fossify/contacts/fragments/MyViewPagerFragment.kt b/app/src/main/kotlin/org/fossify/contacts/fragments/MyViewPagerFragment.kt index bef154ebc..330d9533b 100644 --- a/app/src/main/kotlin/org/fossify/contacts/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/contacts/fragments/MyViewPagerFragment.kt @@ -3,6 +3,8 @@ package org.fossify.contacts.fragments import android.content.Context import android.content.Intent import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View import android.view.ViewGroup import android.widget.RelativeLayout import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -49,6 +51,10 @@ abstract class MyViewPagerFragment(c var skipHashComparing = false var forceListRedraw = false + private var initialY = 0f + private var dY = 0f + private var isPositionRestored = false + fun setupFragment(activity: SimpleActivity) { config = activity.config if (this.activity == null) { @@ -63,6 +69,8 @@ abstract class MyViewPagerFragment(c } innerBinding.fragmentPlaceholder2.underlineText() + setupDragHandle() + restoreSidebarPosition() when (this) { is ContactsFragment -> { @@ -379,6 +387,87 @@ abstract class MyViewPagerFragment(c innerBinding.fragmentList.beVisibleIf(hasItemsToShow) } + private fun setupDragHandle() { + val container = innerBinding.letterFastscrollerContainer ?: return + val dragHandle = innerBinding.letterFastscrollerDragHandle ?: return + val parent = container.parent as? View ?: return + + dragHandle.setOnTouchListener { v, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + isPositionRestored = true + initialY = container.y + dY = event.rawY - initialY + v.performClick() + } + + MotionEvent.ACTION_MOVE -> { + isPositionRestored = true + var newY = event.rawY - dY + val fab = innerBinding.fragmentFab + val parentHeight = parent.height.toFloat() + val fabY = if (fab.isVisible()) fab.y else parentHeight + val maxY = (fabY - container.height).coerceAtLeast(0f) + newY = newY.coerceIn(0f, maxY) + container.y = newY + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (isPositionRestored && container.y != initialY) { + config.sidebarYOffset = container.y + container.height + } + } + } + true + } + + /* + * To ensure the sidebar expands upwards when new characters are added (instead of pushing + * the dot down), we listen for layout changes and adjust the Y position if the height changes. + */ + container.addOnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom -> + if (!isPositionRestored) return@addOnLayoutChangeListener + val oldHeight = oldBottom - oldTop + val newHeight = bottom - top + if (oldHeight > 0 && newHeight != oldHeight) { + val heightDiff = newHeight - oldHeight + var newY = container.y - heightDiff + + val fab = innerBinding.fragmentFab + val parentHeight = parent.height.toFloat() + val fabY = if (fab.isVisible()) fab.y else parentHeight + val maxY = (fabY - newHeight).coerceAtLeast(0f) + + newY = newY.coerceIn(0f, maxY) + if (container.y != newY) { + container.y = newY + } + config.sidebarYOffset = container.y + newHeight + } + } + } + + private fun restoreSidebarPosition() { + val container = innerBinding.letterFastscrollerContainer ?: return + val parent = container.parent as? View ?: return + container.onGlobalLayout { + val fab = innerBinding.fragmentFab + val parentHeight = parent.height.toFloat() + val fabY = if (fab.isVisible()) fab.y else parentHeight + val maxY = (fabY - container.height).coerceAtLeast(0f) + + val savedBottomY = config.sidebarYOffset + val targetY = if (savedBottomY == 0f) { + 0f + } else { + (savedBottomY - container.height).coerceIn(0f, maxY) + } + + container.y = targetY + isPositionRestored = true + } + } + abstract fun fabClicked() abstract fun placeholderClicked() @@ -391,6 +480,8 @@ abstract class MyViewPagerFragment(c val fragmentWrapper: RelativeLayout val letterFastscroller: FastScrollerView? val letterFastscrollerThumb: FastScrollerThumbView? + val letterFastscrollerContainer: ViewGroup? + val letterFastscrollerDragHandle: View? val fragmentFastscroller: RecyclerViewFastScroller? } @@ -402,6 +493,8 @@ abstract class MyViewPagerFragment(c override val fragmentWrapper: RelativeLayout = binding.fragmentWrapper override val letterFastscroller: FastScrollerView = binding.letterFastscroller override val letterFastscrollerThumb: FastScrollerThumbView = binding.letterFastscrollerThumb + override val letterFastscrollerContainer: ViewGroup = binding.letterFastscrollerContainer + override val letterFastscrollerDragHandle: View = binding.letterFastscrollerDragHandle override val fragmentFastscroller: RecyclerViewFastScroller? = null } @@ -413,6 +506,8 @@ abstract class MyViewPagerFragment(c override val fragmentWrapper: RelativeLayout = binding.fragmentWrapper override val letterFastscroller: FastScrollerView? = null override val letterFastscrollerThumb: FastScrollerThumbView? = null + override val letterFastscrollerContainer: ViewGroup? = null + override val letterFastscrollerDragHandle: View? = null override val fragmentFastscroller: RecyclerViewFastScroller = binding.fragmentFastscroller } } diff --git a/app/src/main/kotlin/org/fossify/contacts/helpers/Config.kt b/app/src/main/kotlin/org/fossify/contacts/helpers/Config.kt index 5be4a497f..2526e3964 100644 --- a/app/src/main/kotlin/org/fossify/contacts/helpers/Config.kt +++ b/app/src/main/kotlin/org/fossify/contacts/helpers/Config.kt @@ -18,4 +18,7 @@ class Config(context: Context) : BaseConfig(context) { set(autoBackupContactSources) = prefs.edit().remove(AUTO_BACKUP_CONTACT_SOURCES).putStringSet(AUTO_BACKUP_CONTACT_SOURCES, autoBackupContactSources) .apply() + var sidebarYOffset: Float + get() = prefs.getFloat(SIDEBAR_Y_OFFSET, 0f) + set(sidebarYOffset) = prefs.edit().putFloat(SIDEBAR_Y_OFFSET, sidebarYOffset).apply() } diff --git a/app/src/main/kotlin/org/fossify/contacts/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/contacts/helpers/Constants.kt index c6e8c5a45..75aa3212b 100644 --- a/app/src/main/kotlin/org/fossify/contacts/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/contacts/helpers/Constants.kt @@ -17,6 +17,7 @@ const val AUTOMATIC_BACKUP_REQUEST_CODE = 10001 const val AUTO_BACKUP_INTERVAL_IN_DAYS = 1 const val AUTO_BACKUP_CONTACT_SOURCES = "auto_backup_contact_sources" +const val SIDEBAR_Y_OFFSET = "sidebar_y_offset" // extras used at third party intents const val KEY_NAME = "name" diff --git a/app/src/main/res/drawable/ic_dot.xml b/app/src/main/res/drawable/ic_dot.xml new file mode 100644 index 000000000..ce4bafe46 --- /dev/null +++ b/app/src/main/res/drawable/ic_dot.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/fragment_letters_layout.xml b/app/src/main/res/layout/fragment_letters_layout.xml index 56b20ae14..7105200e1 100644 --- a/app/src/main/res/layout/fragment_letters_layout.xml +++ b/app/src/main/res/layout/fragment_letters_layout.xml @@ -45,22 +45,41 @@ android:paddingBottom="@dimen/secondary_fab_bottom_margin" app:layoutManager="org.fossify.commons.views.MyLinearLayoutManager" /> - + android:gravity="center_horizontal" + android:orientation="vertical"> + + + + + + + android:layout_toStartOf="@+id/letter_fastscroller_container" + android:padding="@dimen/small_margin" /> From d8ec3b63b9e03e9e457f0ba47b86c3afa6c8d074 Mon Sep 17 00:00:00 2001 From: huyennbl <33422297+huyennbl@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:15:29 +0700 Subject: [PATCH 2/3] chore: update unreleased changelog for draggable sidebar --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a92d320..76622932f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Draggable sidebar ## [1.6.0] - 2026-01-30 ### Added From dec8c52acfa533ad8693a08cb2c3c53d28aa0489 Mon Sep 17 00:00:00 2001 From: huyennbl <33422297+huyennbl@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:25:15 +0700 Subject: [PATCH 3/3] chore: refactor to reduce cyclomatic complexity --- .../contacts/fragments/MyViewPagerFragment.kt | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/contacts/fragments/MyViewPagerFragment.kt b/app/src/main/kotlin/org/fossify/contacts/fragments/MyViewPagerFragment.kt index 330d9533b..364f2c76c 100644 --- a/app/src/main/kotlin/org/fossify/contacts/fragments/MyViewPagerFragment.kt +++ b/app/src/main/kotlin/org/fossify/contacts/fragments/MyViewPagerFragment.kt @@ -426,24 +426,33 @@ abstract class MyViewPagerFragment(c * the dot down), we listen for layout changes and adjust the Y position if the height changes. */ container.addOnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom -> - if (!isPositionRestored) return@addOnLayoutChangeListener - val oldHeight = oldBottom - oldTop - val newHeight = bottom - top - if (oldHeight > 0 && newHeight != oldHeight) { - val heightDiff = newHeight - oldHeight - var newY = container.y - heightDiff - - val fab = innerBinding.fragmentFab - val parentHeight = parent.height.toFloat() - val fabY = if (fab.isVisible()) fab.y else parentHeight - val maxY = (fabY - newHeight).coerceAtLeast(0f) - - newY = newY.coerceIn(0f, maxY) - if (container.y != newY) { - container.y = newY - } - config.sidebarYOffset = container.y + newHeight + handleOnLayoutChange(container, parent, top, bottom, oldTop, oldBottom) + } + } + + private fun handleOnLayoutChange( + container: ViewGroup, + parent: View, + top: Int, + bottom: Int, + oldTop: Int, + oldBottom: Int + ) { + if (!isPositionRestored) return + val oldHeight = oldBottom - oldTop + val newHeight = bottom - top + if (oldHeight > 0 && newHeight != oldHeight) { + val heightDiff = newHeight - oldHeight + val fab = innerBinding.fragmentFab + val parentHeight = parent.height.toFloat() + val fabY = if (fab.isVisible()) fab.y else parentHeight + val maxY = (fabY - newHeight).coerceAtLeast(0f) + + val newY = (container.y - heightDiff).coerceIn(0f, maxY) + if (container.y != newY) { + container.y = newY } + config.sidebarYOffset = container.y + newHeight } }