Support drag multi-selection for media gallery.

This commit is contained in:
Sagar
2025-04-11 20:25:38 +05:30
committed by Alex Hart
parent 852541c361
commit 69153cf339
5 changed files with 328 additions and 7 deletions

View File

@@ -175,7 +175,7 @@ class MediaSelectionViewModel(
return store.state.storySendRequirements
}
private fun addMedia(media: Set<Media>) {
fun addMedia(media: Set<Media>) {
val newSelectionList: List<Media> = linkedSetOf<Media>().apply {
addAll(store.state.selectedMedia)
addAll(media)
@@ -269,9 +269,13 @@ class MediaSelectionViewModel(
}
fun removeMedia(media: Media) {
removeMedia(setOf(media))
}
fun removeMedia(media: Set<Media>) {
val snapshot = store.state
val newMediaList = snapshot.selectedMedia - media
val oldFocusIndex = snapshot.selectedMedia.indexOf(media)
val oldFocusIndex = snapshot.selectedMedia.indexOf(media.first())
val newFocus = when {
newMediaList.isEmpty() -> null
media == snapshot.focusedMedia -> newMediaList[Util.clamp(oldFocusIndex, 0, newMediaList.size - 1)]
@@ -282,7 +286,7 @@ class MediaSelectionViewModel(
it.copy(
selectedMedia = newMediaList,
focusedMedia = newFocus,
editorStateMap = it.editorStateMap - media.uri,
editorStateMap = it.editorStateMap - media.map { it.uri },
cameraFirstCapture = if (media == it.cameraFirstCapture) null else it.cameraFirstCapture
)
}
@@ -292,9 +296,9 @@ class MediaSelectionViewModel(
}
selectedMediaSubject.onNext(newMediaList)
repository.deleteBlobs(listOf(media))
repository.deleteBlobs(media.toList())
Log.d(TAG, "User removed ${media.uri} from message.")
Log.d(TAG, "User removed ${media.forEach { it.uri }} from message.")
cancelUpload(media)
}
@@ -450,6 +454,10 @@ class MediaSelectionViewModel(
}
private fun cancelUpload(media: Media) {
cancelUpload(setOf(media))
}
private fun cancelUpload(media: Set<Media>) {
repository.uploadRepository.cancelUpload(media)
}

View File

@@ -15,7 +15,9 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.Stopwatch
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.conversation.ManageContextMenu
@@ -23,6 +25,8 @@ import org.thoughtcrime.securesms.databinding.V2MediaGalleryFragmentBinding
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mediasend.v2.review.MediaGalleryGridItemTouchListener
import org.thoughtcrime.securesms.permissions.PermissionCompat
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
@@ -44,6 +48,10 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
factoryProducer = { MediaGalleryViewModel.Factory(null, null, MediaGalleryRepository(requireContext(), MediaRepository())) }
)
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private lateinit var callbacks: Callbacks
private var selectedMediaTouchHelper: ItemTouchHelper? = null
@@ -54,6 +62,8 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
private val viewStateLiveData = MutableLiveData(ViewState())
private val lifecycleDisposable = LifecycleDisposable()
private val onBackPressedCallback: OnBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
onBack()
@@ -64,6 +74,8 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
callbacks = requireListener()
val binding = V2MediaGalleryFragmentBinding.bind(view)
lifecycleDisposable.bindTo(this)
SystemWindowInsetsSetter.attach(view, viewLifecycleOwner, WindowInsetsCompat.Type.navigationBars())
binding.mediaGalleryToolbar.updateLayoutParams<ConstraintLayout.LayoutParams> {
@@ -130,8 +142,45 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
binding.mediaGallerySelected.adapter = selectedAdapter
selectedMediaTouchHelper?.attachToRecyclerView(binding.mediaGallerySelected)
val mediaGalleryGridItemTouchListener = MediaGalleryGridItemTouchListener()
val onDragSelectListener = object : MediaGalleryGridItemTouchListener.OnDragSelectListener {
override fun onSelectionStarted(start: Int) {
galleryAdapter.getModel(start).ifPresent {
val fileModel = it as MediaGallerySelectableItem.FileModel
val media = fileModel.media
if (fileModel.isSelected) {
callbacks.onMediaUnselected(media)
mediaGalleryGridItemTouchListener.setIsActive(false)
} else {
callbacks.onMediaSelected(media)
}
}
}
override fun onSelectChange(start: Int, end: Int, shouldSelect: Boolean) {
val mediaSet = (start..end)
.mapNotNull { i ->
galleryAdapter.getModel(i).orElse(null) as? MediaGallerySelectableItem.FileModel
}
.map { fileModel ->
fileModel.media
}
.toSet()
if (mediaSet.isNotEmpty()) {
if (shouldSelect) {
callbacks.onMediaSelected(mediaSet)
} else {
callbacks.onMediaUnselected(mediaSet)
}
}
}
}
mediaGalleryGridItemTouchListener.withSelectListener(onDragSelectListener)
MediaGallerySelectableItem.registerAdapter(
mappingAdapter = galleryAdapter,
mediaGalleryGridItemTouchListener = mediaGalleryGridItemTouchListener,
onMediaFolderClicked = {
onBackPressedCallback.isEnabled = true
viewModel.setMediaFolder(it)
@@ -148,6 +197,7 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
binding.mediaGalleryGrid.adapter = galleryAdapter
binding.mediaGalleryGrid.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(2)))
binding.mediaGalleryGrid.addOnItemTouchListener(mediaGalleryGridItemTouchListener)
viewStateLiveData.observe(viewLifecycleOwner) { state ->
binding.mediaGalleryBottomBarGroup.visible = state.selectedMedia.isNotEmpty()
@@ -212,6 +262,13 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
}
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
lifecycleDisposable += sharedViewModel.mediaErrors
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
mediaGalleryGridItemTouchListener.stopAutoScroll()
mediaGalleryGridItemTouchListener.setIsActive(false)
}
}
override fun onResume() {
@@ -285,6 +342,8 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
fun isCameraEnabled(): Boolean = true
fun isMultiselectEnabled(): Boolean = false
fun onMediaSelected(media: Media)
fun onMediaSelected(media: Set<Media>) = Unit
fun onMediaUnselected(media: Set<Media>) = Unit
fun onMediaUnselected(media: Media): Unit = throw UnsupportedOperationException()
fun onSelectedMediaClicked(media: Media): Unit = throw UnsupportedOperationException()
fun onNavigateToCamera(): Unit = throw UnsupportedOperationException()

View File

@@ -19,6 +19,7 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaFolder
import org.thoughtcrime.securesms.mediasend.v2.review.MediaGalleryGridItemTouchListener
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.MediaUtil
@@ -40,12 +41,13 @@ object MediaGallerySelectableItem {
fun registerAdapter(
mappingAdapter: MappingAdapter,
mediaGalleryGridItemTouchListener: MediaGalleryGridItemTouchListener,
onMediaFolderClicked: OnMediaFolderClicked,
onMediaClicked: OnMediaClicked,
isMultiselectEnabled: Boolean
) {
mappingAdapter.registerFactory(FolderModel::class.java, LayoutFactory({ FolderViewHolder(it, onMediaFolderClicked) }, R.layout.v2_media_gallery_folder_item))
mappingAdapter.registerFactory(FileModel::class.java, LayoutFactory({ FileViewHolder(it, onMediaClicked) }, if (isMultiselectEnabled) R.layout.v2_media_gallery_item else R.layout.v2_media_gallery_item_no_check))
mappingAdapter.registerFactory(FileModel::class.java, LayoutFactory({ FileViewHolder(it, onMediaClicked, mediaGalleryGridItemTouchListener) }, if (isMultiselectEnabled) R.layout.v2_media_gallery_item else R.layout.v2_media_gallery_item_no_check))
mappingAdapter.registerFactory(PlaceholderModel::class.java, LayoutFactory({ PlaceholderViewHolder(it) }, R.layout.v2_media_gallery_placeholder_item))
}
@@ -116,7 +118,7 @@ object MediaGallerySelectableItem {
}
}
class FileViewHolder(itemView: View, private val onMediaClicked: OnMediaClicked) : BaseViewHolder<FileModel>(itemView) {
class FileViewHolder(itemView: View, private val onMediaClicked: OnMediaClicked, private val mediaGalleryGridItemTouchListener: MediaGalleryGridItemTouchListener) : BaseViewHolder<FileModel>(itemView) {
private val selectedPadding = DimensionUnit.DP.toPixels(12f)
private val selectedRadius = DimensionUnit.DP.toPixels(12f)
@@ -126,6 +128,10 @@ object MediaGallerySelectableItem {
checkView?.visible = model.isSelected
checkView?.text = "${model.selectionOneBasedIndex}"
itemView.setOnClickListener { onMediaClicked(model.media, model.isSelected) }
itemView.setOnLongClickListener {
mediaGalleryGridItemTouchListener.startDragSelection(bindingAdapterPosition)
true
}
playOverlay?.visible = MediaUtil.isVideo(model.media.contentType) && !model.media.isVideoGif
title?.visible = false

View File

@@ -93,6 +93,14 @@ class MediaSelectionGalleryFragment : Fragment(R.layout.fragment_container), Med
sharedViewModel.removeMedia(media)
}
override fun onMediaSelected(media: Set<Media>) {
sharedViewModel.addMedia(media)
}
override fun onMediaUnselected(media: Set<Media>) {
sharedViewModel.removeMedia(media)
}
override fun onSelectedMediaClicked(media: Media) {
sharedViewModel.onPageChanged(media)
navigator.goToReview(findNavController())

View File

@@ -0,0 +1,240 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v2.review
import android.content.Context
import android.content.res.Resources
import android.view.MotionEvent
import android.view.animation.LinearInterpolator
import android.widget.OverScroller
import androidx.recyclerview.widget.RecyclerView
class MediaGalleryGridItemTouchListener : RecyclerView.OnItemTouchListener {
private var isActive = false
private var start = RecyclerView.NO_POSITION
private var end = RecyclerView.NO_POSITION
private var inTopSpot = false
private var inBottomSpot = false
private var scrollDistance = 0
private var lastX = Float.MIN_VALUE
private var lastY = Float.MIN_VALUE
private var lastStart = RecyclerView.NO_POSITION
private var lastEnd = RecyclerView.NO_POSITION
private var selectListener: OnDragSelectListener? = null
private var recyclerView: RecyclerView? = null
private var scroller: OverScroller? = null
private var topBoundFrom = 0
private var topBoundTo = 0
private var bottomBoundFrom = 0
private var bottomBoundTo = 0
private var maxScrollDistance = 16
private var autoScrollDistance = (Resources.getSystem().displayMetrics.density * 56).toInt()
private var touchRegionTopOffset = 0
private var touchRegionBottomOffset = 0
private var scrollAboveTopRegion = true
private var scrollBelowTopRegion = true
init {
reset()
}
private fun reset() {
isActive = false
start = RecyclerView.NO_POSITION
end = RecyclerView.NO_POSITION
lastStart = RecyclerView.NO_POSITION
lastEnd = RecyclerView.NO_POSITION
inTopSpot = false
inBottomSpot = false
lastX = Float.MIN_VALUE
lastY = Float.MIN_VALUE
stopAutoScroll()
}
fun stopAutoScroll() {
if (scroller != null && !scroller!!.isFinished) {
recyclerView?.removeCallbacks(scrollRunnable)
scroller?.abortAnimation()
}
}
fun withSelectListener(selectListener: OnDragSelectListener): MediaGalleryGridItemTouchListener {
this.selectListener = selectListener
return this
}
private val scrollRunnable = object : Runnable {
override fun run() {
if (scroller != null && scroller!!.computeScrollOffset()) {
scrollBy(scrollDistance)
recyclerView?.postOnAnimation(this)
}
}
}
fun startDragSelection(position: Int) {
isActive = true
start = position
end = position
lastStart = position
lastEnd = position
selectListener?.onSelectionStarted(position)
}
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
if (!isActive || rv.adapter?.itemCount == 0) return false
when (e.action) {
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_DOWN -> reset()
}
recyclerView = rv
val height = rv.height
topBoundFrom = 0 + touchRegionTopOffset
topBoundTo = topBoundFrom + autoScrollDistance
bottomBoundFrom = height + touchRegionBottomOffset - autoScrollDistance
bottomBoundTo = height + touchRegionBottomOffset
return true
}
fun setIsActive(isActive: Boolean) {
this.isActive = isActive
}
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {
if (!isActive) return
when (e.action) {
MotionEvent.ACTION_DOWN -> updateSelectedRange(rv, e)
MotionEvent.ACTION_MOVE -> {
if (!inTopSpot && !inBottomSpot) updateSelectedRange(rv, e)
processAutoScroll(e)
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> reset()
}
}
private fun updateSelectedRange(rv: RecyclerView, e: MotionEvent) {
updateSelectedRange(rv, e.x, e.y)
}
private fun processAutoScroll(event: MotionEvent) {
val y = event.y.toInt()
val scrollSpeedFactor: Float
when {
y in topBoundFrom..topBoundTo -> {
lastX = event.x
lastY = event.y
scrollSpeedFactor = (topBoundTo - topBoundFrom - (y - topBoundFrom)).toFloat() / (topBoundTo - topBoundFrom)
scrollDistance = (maxScrollDistance * scrollSpeedFactor * -1f).toInt()
if (!inTopSpot) {
inTopSpot = true
startAutoScroll()
}
}
scrollAboveTopRegion && y < topBoundFrom -> {
lastX = event.x
lastY = event.y
scrollDistance = -maxScrollDistance
if (!inTopSpot) {
inTopSpot = true
startAutoScroll()
}
}
y in bottomBoundFrom..bottomBoundTo -> {
lastX = event.x
lastY = event.y
scrollSpeedFactor = (y - bottomBoundFrom).toFloat() / (bottomBoundTo - bottomBoundFrom)
scrollDistance = (maxScrollDistance * scrollSpeedFactor).toInt()
if (!inBottomSpot) {
inBottomSpot = true
startAutoScroll()
}
}
scrollBelowTopRegion && y > bottomBoundTo -> {
lastX = event.x
lastY = event.y
scrollDistance = maxScrollDistance
if (!inTopSpot) {
inTopSpot = true
startAutoScroll()
}
}
else -> {
inBottomSpot = false
inTopSpot = false
lastX = Float.MIN_VALUE
lastY = Float.MIN_VALUE
stopAutoScroll()
}
}
}
private fun updateSelectedRange(rv: RecyclerView, x: Float, y: Float) {
val child = rv.findChildViewUnder(x, y)
if (child != null) {
val position = rv.getChildAdapterPosition(child)
if (position != RecyclerView.NO_POSITION && end != position) {
end = position
notifySelectRangeChange()
}
}
}
fun startAutoScroll() {
val context = recyclerView?.context ?: return
initScroller(context)
if (scroller?.isFinished == true) {
recyclerView?.removeCallbacks(scrollRunnable)
scroller?.startScroll(0, scroller!!.currY, 0, 5000, 100000)
recyclerView!!.postOnAnimation(scrollRunnable)
}
}
private fun notifySelectRangeChange() {
if (selectListener == null || start == RecyclerView.NO_POSITION || end == RecyclerView.NO_POSITION) return
val newStart = minOf(start, end)
val newEnd = maxOf(start, end)
when {
lastStart == RecyclerView.NO_POSITION || lastEnd == RecyclerView.NO_POSITION -> {
if (newEnd - newStart == 1) selectListener?.onSelectChange(newStart, newStart, true)
else selectListener?.onSelectChange(newStart, newEnd, true)
}
newStart > lastStart -> selectListener?.onSelectChange(lastStart, newStart - 1, false)
newStart < lastStart -> selectListener?.onSelectChange(newStart, lastStart - 1, true)
}
when {
newEnd > lastEnd -> selectListener?.onSelectChange(lastEnd + 1, newEnd, true)
newEnd < lastEnd -> selectListener?.onSelectChange(newEnd + 1, lastEnd, false)
}
lastStart = newStart
lastEnd = newEnd
}
private fun initScroller(context: Context) {
if (scroller == null) scroller = OverScroller(context, LinearInterpolator())
}
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { }
private fun scrollBy(distance: Int) {
val scrollDist = if (distance > 0) minOf(distance, maxScrollDistance) else maxOf(distance, -maxScrollDistance)
recyclerView?.scrollBy(0, scrollDist)
if (lastX != Float.MIN_VALUE && lastY != Float.MIN_VALUE) {
updateSelectedRange(recyclerView!!, lastX, lastY)
}
}
interface OnDragSelectListener {
fun onSelectionStarted(start: Int)
fun onSelectChange(start: Int, end: Int, shouldSelect: Boolean)
}
}