일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 서비스 vs 쓰레드
- ANR이란
- rxjava cold observable
- 스택 자바 코드
- 안드로이드 유닛 테스트
- 스택 큐 차이
- jvm이란
- 안드로이드 os 구조
- 자바 다형성
- 객체
- 안드로이드 유닛 테스트 예시
- 멤버변수
- 안드로이드 라이선스 종류
- 안드로이드 레트로핏 crud
- rxjava disposable
- jvm 작동 원리
- 플러터 설치 2022
- Rxjava Observable
- 큐 자바 코드
- 2022 플러터 설치
- android ar 개발
- android retrofit login
- ar vr 차이
- 안드로이드 라이선스
- 안드로이드 레트로핏 사용법
- 2022 플러터 안드로이드 스튜디오
- rxjava hot observable
- 클래스
- 서비스 쓰레드 차이
- 안드로이드 유닛테스트란
- Today
- Total
나만을 위한 블로그
[Android Compose] 드래그로 사진 선택 구현하는 법 본문
이 포스팅에선 LazyVerticalGrid에 여러 사진들이 표시될 때 드래그로 여러 사진들을 선택하는 법을 확인한다.
코드는 아래와 같다.
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.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
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.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
@Composable
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)
.clip(RoundedCornerShape(roundedCornerShape))
)
if (inSelectionMode) {
if (selected) {
val bgColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
Icon(
Icons.Filled.Favorite,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier
.padding(4.dp)
.border(2.dp, bgColor, CircleShape)
.clip(CircleShape)
.background(bgColor)
)
} else {
Icon(
Icons.Filled.FavoriteBorder,
tint = Color.White.copy(alpha = 0.7f),
contentDescription = null,
modifier = Modifier.padding(6.dp)
)
}
}
}
}
}
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.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.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.geometry.Offset
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.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.toIntRect
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
data class Photo(
val id: Int,
val url: String = "https://picsum.photos/seed/${(0..100000).random()}/256/256"
)
@Composable
fun 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 = 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
}
}
)
} else {
Modifier
}
)
)
}
}
}
// 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
fun Modifier.photoGridDragHandler(
lazyGridState: LazyGridState,
haptics: HapticFeedback,
selectedIds: MutableState<Set<Int>>,
autoScrollSpeed: MutableState<Float>,
autoScrollThreshold: Float
) = pointerInput(Unit) {
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
}
}
}
}
)
}
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import com.example.composepractice.multi_selection.draggable.PhotosGrid
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
PhotosGrid()
}
}
}
}
}
실행하면 아래와 같이 작동한다.
왼쪽 밑, 오른쪽 밑으로 드래그한 후 손가락을 놓지 않고 그대로 있으면 다음 사진들까지 이어서 선택할 수 있다.
이제 어떻게 이런 처리가 가능한지 확인해 본다. ImageItem은 LazyVerticalGrid에 표시되는 사진들을 렌더링하는 파일로 selected에 따라 패딩, 배경색을 동적으로 애니메이션 처리한다. 그래서 선택할 때마다 하트가 채워지면서 이미지 상하좌우로 패딩이 적용되며 이미지 모서리들이 둥글게 변하는 걸 볼 수 있다.
PhotoGrid는 LazyVerticalGrid를 써서 사진들을 리스트 형태로 표시하고 photoGridDragHandler Modifier를 써서 사진들을 드래그할 수 있게 한다. 크기는 128dp로 하드코딩돼 있다.
fun LazyGridState.gridItemKeyAtPosition(hitPoint: Offset): Int? =
layoutInfo.visibleItemsInfo.find { itemInfo ->
itemInfo.size.toIntRect().contains(hitPoint.round() - itemInfo.offset)
}?.key as? Int
fun Modifier.photoGridDragHandler(
lazyGridState: LazyGridState,
haptics: HapticFeedback,
selectedIds: MutableState<Set<Int>>,
autoScrollSpeed: MutableState<Float>,
autoScrollThreshold: Float
) = pointerInput(Unit) {
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
}
}
}
}
)
}
gridItemKeyAtPosition()은 드래그 위치 오프셋에 해당하는 그리드 아이템의 key를 리턴하며 이를 통해 드래그 위치가 포함된 아이템을 찾는다.
드래그로 여러 이미지를 선택할 때 현재 터치가 어느 아이템 위에 있는지 식별할 수 있어야 선택 상태를 업데이트할 수 있기 때문에 필요한 함수다.
photoGridDragHandler()의 detectDragGesturesAfterLongPress()가 유저의 드래그 제스처를 감지해서 여러 이미지의 선택 상태를 변경하고 자동 스크롤도 처리하는 부분이다.
화면에서 위 또는 아래로 드래그한 거리에 따라 자동 스크롤 속도를 조정하는 부분은 onDrag 함수다. 새로운 위치에 있는 아이템의 key를 찾아 그 영역을 업데이트한다.
코드 흐름을 정리하면 아래와 같다.
- 유저가 롱클릭 후 드래그해서 detectDragGesturesAfterLongPress()가 호출되면 onDragStart가 호출되어 드래그를 시작한 위치의 오프셋과 그곳에 있는 아이템의 key를 가져온다
- 예전에 선택한 아이템이 아니라면 initialKey, currentKey를 초기화해서 드래그 시작 위치를 저장하고 selectedIds.value에 선택한 아이템을 추가한다
- 드래그가 시작되면 onDrag가 호출된다. 이 때 유저가 이동한 위치에 따라 아이템을 동적으로 선택하거나 자동 스크롤을 처리한다
- 드래그 위치가 화면 밑에 가까워지면 밑으로, 화면 위에 가까워지면 위로 스크롤한다
- 스크롤 속도인 autoScrollSpeed는 autoScrollThreshold와의 거리에 따라 계산된다
- gridItemKeyAtPosition()으로 현재 드래그한 위치에 있는 아이템의 key를 가져오고, currentKey와 다르면 새로 드래그된 범위인 initialKey~currentKey를 선택하거나 선택해제한 다음 selectedIds.value를 업데이트한다. 이 때 minus, plus를 써서 기존 선택 범위를 제거하고 새로 드래그된 범위를 추가한다
- 드래그 동작이 끝나면 onDragEnd, 드래그가 중단되면 onDragCancel이 호출된다. 이 때 자동 스크롤 속도를 0f로 초기화해서 어느 방향으로든 스크롤되지 않게 하고, initialKey를 null로 초기화해서 새 드래그 범위가 담길 수 있게 한다
참고로 스크롤 속도는 0.01초(10ms) 간격으로 업데이트된다.
LaunchedEffect(autoScrollSpeed.value) {
if (autoScrollSpeed.value != 0f) {
while (isActive) {
state.scrollBy(autoScrollSpeed.value)
delay(10)
}
}
}
delay()를 10으로 설정해서 자동 스크롤이 부드럽게 이뤄지게 했는데 이러면 스크롤 업데이트가 초당 100회 실행되는 것이니 실제로 사용한다면 핸드폰 CPU에 큰 부담을 줄 수 있다. delay() 안의 숫자를 적절하게 바꾸거나 리팩토링해야 한다.
'Android > Compose' 카테고리의 다른 글
[Android Compose] Supabase를 활용한 CRUD 구현 - 2 - (0) | 2025.01.11 |
---|---|
[Android Compose] Supabase를 활용한 CRUD 구현 - 1 - (0) | 2025.01.06 |
[Android Compose] LazyColumn을 활용한 복수 선택 기능 구현 (0) | 2024.12.22 |
[Android Compose] 무한 캐러셀 구현하는 법 (0) | 2024.12.21 |
[Android Compose] 이미지 압축 구현하기 (0) | 2024.12.08 |