使用Jetpack Compose实现具有多选功能的图片网格
在现代应用中,多选功能是一项常见且重要的需求。例如,Google Photos允许用户轻松选择多个照片进行分享、添加到相册或删除。在本文中,我们将展示如何使用Jetpack Compose实现类似的多选行为,最终效果如下:
主要步骤
1. 实现基本网格
2. 为网格元素添加选择状态
3. 添加手势处理,实现拖动选择/取消选择元素
4. 完善界面,使元素看起来像照片
实现基本网格
首先,我们使用LazyVerticalGrid
实现基本网格,使应用能够在所有屏幕尺寸上良好运行。较大屏幕显示更多列,较小屏幕显示更少列。以下代码展示了如何实现一个简单的网格:
@Composable
privatefun 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)
){}
}
}
}
在这里,我们使用LazyVerticalGrid
创建一个自适应的网格,并使用PhotoItem
来展示每个元素。尽管目前只是显示一个简单的有色Surface
,但我们已经有了一个可以滚动的网格。
添加选择状态
为了实现多选功能,我们需要追踪当前选中的元素及是否处于选择模式,并使网格元素反映这些状态。首先,将网格元素提取为独立的可组合项PhotoItem
,并反映其选择状态:
@Composable
privatefun 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)
}
}
}
}
这个可组合项根据传入的状态显示不同的图标。当用户点击一个元素时,我们将其ID添加或移除到选中集合中,并根据选中集合的状态确定是否处于选择模式:
@Composable
privatefun 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)
}
})
}
}
}
添加手势处理
现在我们追踪了选中状态,可以实现手势处理来增加和移除选中的元素。我们的需求如下:
1. 通过长按进入选择模式
2. 长按后拖动以选择或取消选择从起点到终点的所有元素
3. 在选择模式下,点击元素以选择或取消选择它们
4. 长按已选中的元素不执行任何操作
为了处理这些手势,我们需要在网格中添加手势处理,而不是在单个元素中添加。我们使用LazyGridState
和拖动位置来检测当前指针指向的元素:
@Composable
privatefun 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
方法设置拖动处理。我们在onDragStart
中设置初始元素,在onDrag
中更新当前指针指向的元素。
fun Modifier.photoGridDragHandler(
lazyGridState: LazyGridState,
selectedIds: MutableState<Set<Int>>
) = pointerInput(Unit){
var initialKey:Int?=null
var currentKey:Int?=null
detectDragGesturesAfterLongPress(
onDragStart ={ offset ->..},
onDragCancel ={ initialKey =null},
onDragEnd ={ initialKey =null},
onDrag ={ change, _ ->..}
)
}
onDragStart = { offset ->
lazyGridState.gridItemKeyAtPosition(offset)?.let{ key ->// #1
if(!selectedIds.value.contains(key)){// #2
initialKey = key
currentKey = key
selectedIds.value = selectedIds.value + key // #3
}
}
}
辅助方法
我们需要实现一个辅助方法gridItemKeyAtPosition
来检测指针当前位置的元素:
// 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
该方法检查指针位置是否在某个可见元素的范围内,并返回该元素的索引。
现在我们只需要更新onDrag
方法,当用户在屏幕上来回拖到的时候便会被i调用。
onDrag = { 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
}
}
}
}
通过这个方法,我们可以实现拖到多选了。
最终步骤:替换单个元素的点击行为并考虑无障碍性
我们需要替换单个元素的点击行为,以便在选择模式下添加或移除选中的元素。这也是考虑手势处理器无障碍性的合适时机。我们使用的pointerInput
修饰符创建的自定义拖动手势不支持无障碍功能,因此像Talkback这样的服务不会包含长按和拖动行为。相反,我们可以为无障碍服务用户提供替代的选择机制,让他们通过长按元素进入选择模式。我们通过设置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,// 不显示波纹效果
onValueChange ={
if(it){
selectedIds.value += id
}else{
selectedIds.value -= id
}
}
)
}elseModifier)
)
语义修饰符允许你覆盖或添加无障碍服务用来与屏幕交互的属性和操作处理程序。大多数情况下,Compose系统会自动为你处理这些操作,但在这种情况下,我们需要明确添加长按行为。
此外,通过为元素使用toggleable
修饰符(并且仅在选择模式下添加),我们确保Talkback可以向用户提供有关当前元素选中状态的信息。
在拖动过程中添加滚动功能
在上面的屏幕录制中可以看到,我们目前无法拖动超过屏幕的顶部和底部边缘。这限制了选择机制的功能。当指针接近屏幕边缘时,我们希望网格能够滚动。此外,指针越接近屏幕边缘,滚动速度应该越快。
目标效果
用户在屏幕上拖动以多选元素,当到达屏幕底部时,网格会向下滚动以允许更多选择。
实现滚动
首先,我们将更改拖动处理程序,以便根据与容器顶部或底部的距离设置滚动速度:
fun Modifier.photoGridDragHandler(
lazyGridState: LazyGridState,
selectedIds: MutableState<Set<Int>>,
autoScrollSpeed: MutableState<Float>,
autoScrollThreshold: Float
) = pointerInput(Unit){
//..
detectDragGesturesAfterLongPress(
onDragStart ={ offset ->..},
onDragCancel ={ initialKey =null; autoScrollSpeed.value =0f},
onDragEnd ={ initialKey =null; autoScrollSpeed.value =0f},
onDrag ={ 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 ->..}
}
}
}
)
}
在这个代码片段中,我们根据阈值和距离更新滚动速度,并确保在拖动结束或取消时重置滚动速度。
现在,仅更改手势处理程序中的滚动速度值还没有任何效果。我们需要更新 PhotoGrid
组件,使其在值更改时开始滚动网格:
@Composable
privatefun 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
会重新触发,滚动将重新开始。
你可能会问,为什么我们不直接在 onDrag
处理程序中更改滚动级别。原因是 onDrag
lambda 只有在用户实际移动指针时才会被调用!所以如果用户将手指保持在屏幕上非常静止,滚动就会停止。你可能在某些应用中注意到过这个滚动错误,需要不断在屏幕底部来回移动才能让其滚动。
最终调整
通过这个最后的添加,我们的网格行为已经非常稳固。然而,它看起来并不像我们在博客文章开头提到的示例。让我们确保网格项反映实际照片:
private classPhoto(val id:Int,val url:String)
@Composable
privatefun 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
privatefun 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.dpelse0.dp
}
val roundedCornerShape by transition.animateDp(label ="corner"){ selected ->
if(selected)16.dpelse0.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"
如你所见,我们扩展了照片列表,使其除了 id
外还有一个 URL。使用该 URL,我们可以在网格项中加载图片。在选择模式之间切换时,图片的填充和角形状会发生变化,并且我们使用动画使这种变化平滑出现。
结论
完整代码如下, 用不到200行代码创建了一个功能强大的UI,包括丰富的交互。通过本文,你可以学到如何使用Jetpack Compose实现一个多选照片网格,并添加无障碍支持和滚动功能。希望这些内容能对你的项目有所帮助!
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import com.example.myapplication.ui.theme.MyApplicationTheme
import kotlinx.coroutines.delay
privateclassPhoto(
val id:Int,
val url:String="https://picsum.photos/seed/${(0..100000).random()}/256/256"
)
@Composable
privatefun PhotosGrid(
photos: List<Photo> = List(100){Photo(it)},
selectedIds:MutableState<Set<Int>>= rememberSaveable { mutableStateOf(emptySet())}
){
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty()}}
val state = rememberLazyGridState()
val autoScrollSpeed = remember { mutableStateOf(0f)}
LaunchedEffect(autoScrollSpeed.value){
if(autoScrollSpeed.value !=0f){
while(isActive){
state.scrollBy(autoScrollSpeed.value)
delay(10)
}
}
}
LazyVerticalGrid(
state = state,
columns =GridCells.Adaptive(minSize =128.dp),
verticalArrangement =Arrangement.spacedBy(3.dp),
horizontalArrangement =Arrangement.spacedBy(3.dp),
modifier =Modifier.photoGridDragHandler(
lazyGridState = state,
haptics =LocalHapticFeedback.current,
selectedIds = selectedIds,
autoScrollSpeed = autoScrollSpeed,
autoScrollThreshold =with(LocalDensity.current){40.dp.toPx()}
)
){
items(photos, key ={ it.id }){ photo ->
val selected by remember { derivedStateOf { selectedIds.value.contains(photo.id)}}
ImageItem(
photo, inSelectionMode, selected,
Modifier
.semantics {
if(!inSelectionMode){
onLongClick("Select"){
selectedIds.value += photo.id
true
}
}
}
.then(if(inSelectionMode){
Modifier.toggleable(
value = selected,
interactionSource = remember {MutableInteractionSource()},
indication =null,// do not show a ripple
onValueChange ={
if(it){
selectedIds.value += photo.id
}else{
selectedIds.value -= photo.id
}
}
)
}elseModifier)
)
}
}
}
fun Modifier.photoGridDragHandler(
lazyGridState: LazyGridState,
haptics: HapticFeedback,
selectedIds: MutableState<Set<Int>>,
autoScrollSpeed: MutableState<Float>,
autoScrollThreshold: Float
)= pointerInput(Unit){
fun LazyGridState.gridItemKeyAtPosition(hitPoint: Offset):Int?=
layoutInfo.visibleItemsInfo.find { itemInfo ->
itemInfo.size.toIntRect().contains(hitPoint.round()- itemInfo.offset)
}?.key as?Int
var initialKey:Int?=null
var currentKey:Int?=null
detectDragGesturesAfterLongPress(
onDragStart ={ offset ->
lazyGridState.gridItemKeyAtPosition(offset)?.let{ key ->
if(!selectedIds.value.contains(key)){
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
initialKey = key
currentKey = key
selectedIds.value += key
}
}
},
onDragCancel ={ initialKey =null; autoScrollSpeed.value =0f},
onDragEnd ={ initialKey =null; autoScrollSpeed.value =0f},
onDrag ={ change, _ ->
if(initialKey !=null){
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
}
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
}
}
}
}
)
}
@Composable
privatefun 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.dpelse0.dp
}
val roundedCornerShape by transition.animateDp(label ="corner"){ selected ->
if(selected)16.dpelse0.dp
}
Image(
painter = rememberAsyncImagePainter(photo.url),
contentDescription =null,
modifier =Modifier
.matchParentSize()
.padding(padding)
.clip(RoundedCornerShape(roundedCornerShape))
)
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)
)
}
}
}
}
}
@Preview
@Composable
privatefun MyApp(){
MyApplicationTheme{PhotosGrid()}
}