توی خیلی از اپ ها شاید دیده باشین که برای انتخاب کردن(selecting) چند آیتم از یه ویژگی باحال استفاده میکنن بنام انتخاب چندگانه (multiple selection)؛ اینجوریه که وقتی کاربر یه آیتم رو یکم نگه داره و بکشه به پایین یا بالا (dragging) همونقدری که بالا یا پایین کشیده، آیتم ها هم انتخاب یا از حالت انتخاب در میان(deselection).
قدم هایی که باید برای پیاده سازی این برداریم :
برای پیاده سازی grid از LazyVerticalGrid استفاده میکنم تا اپ مون توی screen size های مختلف خوب کار کنه؛ اینطوری که توی screen های بزرگتر، ستون های بیشتری باشه و توی screen های کوچکتر ستون کمتری نمایش داده بشه.
@Composable private fun PhotoGrid() { val photos by rememberSaveable { mutableStateOf(List(100) { it }) } LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 128.dp), verticalArrangement = Arrangement.spacedBy(3.dp), horizontalArrangement = Arrangement.spacedBy(3.dp) ) { items(photos, key = { it }) { Surface( tonalElevation = 3.dp, modifier = Modifier.aspectRatio(1f) ) {} } } }
توی این مثال ما از عکس بعنوان آیتم های grid مون استفاده میکنیم، اما فعلا تا به اضافه کردن عکس ها برسیم، از یه Surface استفاده میکنم. ما الان با این چند خط کُد یه grid عالی داریم که میتونیم scroll کنیم.
ما باید بدونیم که الان چه آیتمی انتخاب(select) شده و اگر تو حالت انتخاب(selection) بودیم، میایم با توجه به وضعیت انتخاب (selection mode) آیتم هامون رو منعکس* میکنیم (*مثلا یه آیکون ✅ مثل این میزاریم روی آیتم ها برای منعکس کردن حالت انتخاب شده یا selected)
خب، اول بیاید آیتم grid مون رو بسازیم و selection state رو اینجوری براش تعیین کنیم :
1- اگر کاربر در حالت انتخاب نبود، هیچی نشون نده
2- وقتی کاربر تو حالت انتخابه ولی آیتم select نشده، بیا یه radioButton نمایش بده
3- وقتی کاربر تو حالت انتخابه و آیتم هم select شده، یه checkmark نمایش بده
@Composable private fun ImageItem( selected: Boolean, inSelectionMode: Boolean, modifier: Modifier ) { Surface( tonalElevation = 3.dp, contentColor = MaterialTheme.colorScheme.primary, modifier = modifier.aspectRatio(1f) ) { if (inSelectionMode) { if (selected) { Icon(Icons.Default.CheckCircle, null) } else { Icon(Icons.Default.RadioButtonUnchecked, null) } } } }
این composable چون stateless بنابراین state رو درون خودش نگه داری نمیکنه ولی بسادگی میتونیم state رو بهش پاس بدیم از طریق argument و این method با توجه به state ای که ما بهش پاس دادیم به ما پاسخ میده.
برای اینکه آیتم ها به حالتهای انتخاب شده (selected) شون پاسخ بدن، grid باید این حالتها را پیگیری کنه. همچنین، کاربر باید با تعامل با آیتم های موجود در grid، مقدار انتخاب شده رو تغییر بده. الان، وقتی که کاربر روی یه آیتم کلیک کنه، به راحتی وضعیت انتخاب شده یه آیتم رو میتونیم اینجوری تغییر بدیم:
@Composable private fun PhotoGrid() { val photos by rememberSaveable { mutableStateOf(List(100) { it }) } val selectedIds = rememberSaveable { mutableStateOf(emptySet<Int>()) } // NEW val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } // NEW LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 128.dp), verticalArrangement = Arrangement.spacedBy(3.dp), horizontalArrangement = Arrangement.spacedBy(3.dp) ) { items(photos, key = { it }) { id -> val selected = selectedIds.value.contains(id) // NEW ImageItem(selected, inSelectionMode, Modifier.clickable { // NEW selectedIds.value = if (selected) { selectedIds.value.minus(id) } else { selectedIds.value.plus(id) } }) } } }
ما الان آیتم های انتخاب شده(selected) رو توی یه set داریم پیگیری میکنیم و کاربر هر زمانیکه خواست روی یه ImageItem کلیک کنه، id همون آیتم از set اضافه یا حذف میشه .
ما زمانی تو حالت انتخاب selectionMode هستیم که آیتم های انتخاب شده وجود داشته باشن؛ هر زمانی که set آیدی های انتخاب شده (selected) تغییر کنه، متغیر selected هم بطور اتوماتیک دوباره محاسبه میشه.
خب تا الان ما میتونیم آیتم هارو اضافه یا حذف کنیم از selection با کلیک کردن روی اونها:
حالا ما وضعیت انتخاب آیتم ها(selection state) رو داریم، میتونیم حرکتی(gesture) که عناصر باید هنگام اضافه یا حذف شدن از selection داشته باشند رو پیاده سازی کنیم. قاعدتا gesture رو باید به grid اضافه کنیم.
برای پیاده سازی این باید این کارا رو بکنیم :
مورد دوم یکم چالشی تره؛ چون برای پیاده سازی اون حرکت(gesture) برای grid مون، ما نیاز داریم :
وقتی کاربر یه آیتم رو long press میکنه و شروع به حرکت کشیدن و select کردن آیتم ها میکنه، اولا باید بدونیم کاربر از کدوم آیتم شروع به حرکت کرده (initial key) و وقتی کاربر به حرکت (gesture drag) ادامه داد ما باید اون آیتم های فعلی ای که کاربر روی اون drag کرده رو داشته باشیم و به طور مداوم آپدیتش کنیم (current key) تا بدونیم که کدوم آیتم ها select/deselect شده. حالا این اطلاعات(initial key و current key) رو از کجا بیاریم تا توی gesture مون ازش استفاده کنیم؟ lazyGrid توی compose از طریق state اش اطلاعاتی مانند : position و size و .. رو داره و ما میتونیم به این اطلاعات دسترسی داشته باشیم با استفاده از LazyGridState و بعد پاسِش بدیم به gesture handler مون.
@Composable private fun PhotoGrid() { val photos by rememberSaveable { mutableStateOf(List(100) { it }) } val selectedIds = rememberSaveable { mutableStateOf(emptySet<Int>()) } val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } val state = rememberLazyGridState() // NEW LazyVerticalGrid( state = state, // NEW columns = GridCells.Adaptive(minSize = 128.dp), verticalArrangement = Arrangement.spacedBy(3.dp), horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.photoGridDragHandler(state, selectedIds) // NEW ) { //.. } }
ما میتونیم از pointerInput و متد detectDragGesturesAfterLongPress استفاده کنیم برای تنظیم و مدیریت drag عمل کشیدن.
توضیحی در مورد pointerinput modifier : این modifier حرکت (gesture) لمس، ماوس و قلم رو تشخیص میده و مدیریت میکنه.
fun Modifier.photoGridDragHandler( lazyGridState: LazyGridState, selectedIds: MutableState<Set<Int>> ) = pointerInput(Unit) { var initialKey: Int? = null var currentKey: Int? = null detectDragGesturesAfterLongPress( = { offset -> .. }, onDragCancel = { initialKey = null }, = { initialKey = null }, = { change, _ -> .. } ) }
خب حالا بیاین on drag start رو پیاده سازی کنیم :
on DragStart = { offset -> lazyGridState.gridItemKeyAtPosition(offset)?.let { key -> // #1 if (!selectedIds.value.contains(key)) { // #2 initialKey = key currentKey = key selectedIds.value = selectedIds.value + key // #3 } } }
بیاین ببینم چه خبره توش: gridItemKeyAtPosition این متد میاد :
1- با استفاده از position، size و .. grid مون
2- و با استفاده از موقعیت نشانگر (pointer position) که حاوی مختصات x و y هستش که کاربر لمس کرده(همون offset) --> با استفاده از اینا میاد آیتمی که کاربر long press کرده و از اونجا drag کردن رو شروع کرده رو بهمون میده.
* اگه اون آیتم رو پیدا کرد و آیتِمِه select نشده باشه هنوز، میاد آیدی آیتم رو واسه initialKey و currentKey سِت میکنه و اون آیتمه(id یا key) رو به لیست انتخاب شده ها اضافه میکنه.
خب ما الان باید این اکستنشن فانکشن (gridItemKeyAtPosition) رو برای LazyGridState بنویسیم :
// The key of the photo underneath the pointer. Null if no photo is hit by the pointer. fun LazyGridState.gridItemKeyAtPosition(hitPoint: Offset): Int? = layoutInfo.visibleItemsInfo.find { itemInfo -> itemInfo.size.toIntRect().contains(hitPoint.round() - itemInfo.offset) }?.key as? Int
این متد میاد چک میکنه: اگر موقعیت نشانگر(hit point یا همون انگشت کاربر فرض کنین) مختصات x و y اش با مختصات آیتمی توی grid مطابقت داشت، میاد key یا همون id اون آیتم رو return میکنه.
خب حالا فقط باید on drag lambda رو آپدیت کنیم؛ این متد : بطور مرتب وقتی که کاربر نشانگر اش(انگشت، قلم، ماوس و..) رو روی screen حرکت میده، صدا زده میشه.
on Drag = { change, _ -> if (initialKey != null) { // Add or remove photos from selection based on drag position lazyGridState.gridItemKeyAtPosition(change.position)?.let { key -> if (currentKey != key) { selectedIds.value = selectedIds.value .minus(initialKey!!..currentKey!!) .minus(currentKey!!..initialKey!!) .plus(initialKey!!..key) .plus(key..initialKey!!) currentKey = key } } } }
عمل کشیدن (dragging) قاعدتا زمانی انجام میشه که initialKey سِت شده باشه و این lambda میاد براساس initialKey و currentKey، آیتم های select شده رو آپدیت میکنه --> در نتیجه مطمئن میشیم که همه آیتم های بین initialKey و currentKey انتخاب شدند.
الان ما میتونیم چندین آیتم رو انتخاب کنیم با drag کردن :
خود grid یه رفتاری برای کلیک کردن داره (clickable behavior) ولی ما نمیخوایم اونجوری باشه، میخوایم رفتاری داشته باشیم که هم بتونیم آیتم هارو اضافه/حذف کنیم و هم accessibility و سرویس هایی مثل Talkback گوگل رو ساپورت کنه. ما این کار رو با استفاده از semantic property onLongClick انجام میدیم:
ImageItem(inSelectionMode, selected, Modifier .semantics { if (!inSelectionMode) { onLongClick("Select") { selectedIds.value += id true } } } .then(if (inSelectionMode) { Modifier.toggleable( value = selected, interactionSource = remember { MutableInteractionSource() }, indication = null, // do not show a ripple onValueChange = { if (it) { selectedIds.value += id } else { selectedIds.value -= id } } ) } else Modifier) )
این Semantic modifier یه سری معنی های اضافه ای به UI تون میده تا سرویس های accessibility مثل Talkback بتونه UI تون رو بفهمه و بدون اینکه کاربر صفحه رو لمس کنه این سرویس(مثلا) میاد مثل یه واسط بین کاربر و UI ما قرار میگیره و برای کاربر توضیح میده و هر عملی که کاربر میخواد رو انجام میده با UI.
و با استفاده از toggleable هم مطمئن میشیم اگه کاربری از سرویس Talkback استفاده میکنه، میتونه اطلاعاتی درباره وضعیت انتخاب شده فعلی داشته باشه.
ما میخوایم وقتی کاربر به لبه screen رسید آیتم ها scroll بشن و همچنین هرچقدر که کاربر به لبه نزدیک تر میشه، ما باید سرعت scroll رو هم افزایش بدیم.
نتیجه اش یه همچین چیزی میشه :
اول، ما باید کنترل کننده drag مون رو تغییر بدیم تا بتونیم سرعت پیمایش رو بر اساس فاصله از بالا یا پایین grid مون تنظیم کنیم :
fun Modifier.photoGridDragHandler( lazyGridState: LazyGridState, selectedIds: MutableState<Set<Int>>, autoScrollSpeed: MutableState<Float>, autoScrollThreshold: Float ) = pointerInput(Unit) { //.. detectDragGesturesAfterLongPress( = { offset -> .. }, onDragCancel = { initialKey = null; autoScrollSpeed.value = 0f }, = { initialKey = null; autoScrollSpeed.value = 0f }, = { change, _ -> if (initialKey != null) { // NEW // If dragging near the vertical edges of the grid, start scrolling val distFromBottom = lazyGridState.layoutInfo.viewportSize.height - change.position.y val distFromTop = change.position.y autoScrollSpeed.value = when { distFromBottom < autoScrollThreshold -> autoScrollThreshold - distFromBottom distFromTop < autoScrollThreshold -> -(autoScrollThreshold - distFromTop) else -> 0f } // Add or remove photos from selection based on drag position lazyGridState.gridItemKeyAtPosition(change.position) ?.let { key -> .. } } } } ) }
الان این تغییراتی که برای سرعت اسکرول تو کنترل کننده gesture مون انجام دادیم کافی نیست، باید PhotoGrid رو هم آپدیت کنیم تا تغییرات سرعت توی اسکول لحاظ بشه :
@Composable private fun PhotoGrid() { //.. // How fast the grid should be scrolling at any given time. The closer the // user moves their pointer to the bottom of the screen, the faster the scroll. val autoScrollSpeed = remember { mutableStateOf(0f) } // Executing the scroll LaunchedEffect(autoScrollSpeed.floatValue) { if (autoScrollSpeed.floatValue != 0f) { while (isActive) { state.scrollBy(autoScrollSpeed.floatValue) delay(10) } } } LazyVerticalGrid( //.. modifier = Modifier.photoGridDragHandler( lazyGridState = state, selectedIds = selectedIds, autoScrollSpeed = autoScrollSpeed, // NEW autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() } // NEW ) ) { items(photos, key = { it }) { id -> //.. } } }
هر وقت مقدار سرعت اسکرول تغییر کرد، LaunchedEffect با خبر میشه و اونو لحاظ میکنه داخل اسکرول.
خب بیاید با یه سری اضافات و اضافه کردن عکس ها، به مثال ایده آل مون برسیم :
private class Photo(val id: Int, val url: String) @Composable private fun PhotoGrid() { val photos by rememberSaveable { mutableStateOf(List(100) { Photo(it, randomSampleImageUrl()) }) } .. } /** * A square image that can be shown in a grid, in either selected or deselected state. */ @Composable private fun ImageItem( photo: Photo, inSelectionMode: Boolean, selected: Boolean, modifier: Modifier = Modifier ) { Surface( modifier = modifier.aspectRatio(1f), tonalElevation = 3.dp ) { Box { val transition = updateTransition(selected, label = "selected") val padding by transition.animateDp(label = "padding") { selected -> if (selected) 10.dp else 0.dp } val roundedCornerShape by transition.animateDp(label = "corner") { selected -> if (selected) 16.dp else 0.dp } Image( painter = rememberAsyncImagePainter(photo.url), contentDescription = null, modifier = Modifier .matchParentSize() .padding(padding.value) .clip(RoundedCornerShape(roundedCornerShape.value)) ) if (inSelectionMode) { if (selected) { val bgColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) Icon( Icons.Filled.CheckCircle, tint = MaterialTheme.colorScheme.primary, contentDescription = null, modifier = Modifier .padding(4.dp) .border(2.dp, bgColor, CircleShape) .clip(CircleShape) .background(bgColor) ) } else { Icon( Icons.Filled.RadioButtonUnchecked, tint = Color.White.copy(alpha = 0.7f), contentDescription = null, modifier = Modifier.padding(6.dp) ) } } } } } fun randomSampleImageUrl() = "https://picsum.photos/seed/${(0..100000).random()}/256/256"
خُبب! امیدوارم خوشتون اومده باشه، اگه نظری، پیشنهادی، باگی چیزی بود لطفا تو کامنت ها به اشتراک بذارین.