Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project Purpose
This project implements an image picker library for Android using Jetpack Compose.
v1 focuses on the core image selection flow.
v1 focuses on the core image selection flow. continue to work v3

### Development Goals
- Implement a reusable Image Picker component for use across apps
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
Expand Down Expand Up @@ -62,8 +62,8 @@ class MainActivity : ComponentActivity() {

@Composable
private fun PickerHost(modifier: Modifier = Modifier) {
var showPicker by remember { mutableStateOf(false) }
var selectedImages by remember { mutableStateOf(emptyList<PickedImage>()) }
var showPicker by rememberSaveable { mutableStateOf(false) }
var selectedImages by rememberSaveable { mutableStateOf(emptyList<PickedImage>()) }

if (showPicker) {
DynamicImagePicker(
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.parcelize) apply false
}
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ coroutines = "1.9.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
turbine = "1.2.0"
mockk = "1.13.13"

exifinterface = "1.4.2"
paging = "3.3.6"
Expand Down Expand Up @@ -63,6 +65,9 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" }
androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
Expand All @@ -72,3 +77,4 @@ android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
5 changes: 5 additions & 0 deletions imagepicker/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.parcelize)
id("maven-publish")
id("signing")
}
Expand Down Expand Up @@ -181,6 +182,10 @@ dependencies {

// Testing
testImplementation(libs.junit)
testImplementation(kotlin("test"))
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
testImplementation(libs.mockk)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,6 @@ internal class ImageFileDataSource(

/**
* 캐시 파일에 대한 content:// URI를 반환한다.
* file:// URI는 API 24+ 에서 앱 간 공유 시 FileUriExposedException을 유발하므로
* FileProvider를 통해 content:// URI로 변환한다.
*/
private fun uriForCacheFile(file: File): Uri {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package io.github.seunghee17.imagepicker

import android.os.Parcelable
import kotlinx.parcelize.Parcelize

/**
* 정규화된 크롭 영역. 모든 값은 이미지 크기 대비 [0f, 1f] 범위.
*/
@Parcelize
data class CropRect(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float
) {
) : Parcelable {
init {
require(left >= 0f && top >= 0f && right <= 1f && bottom <= 1f) {
"CropRect 값은 [0f, 1f] 범위 내에 있어야 합니다."
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package io.github.seunghee17.imagepicker

import android.net.Uri
import io.github.seunghee17.imagepicker.CropRect
import android.os.Parcelable
import kotlinx.parcelize.Parcelize

@Parcelize
data class PickedImage(
val originalUri: Uri,
val editedUri: Uri? = null,
Expand All @@ -11,4 +13,4 @@ data class PickedImage(
val isCropped: Boolean = cropRect != null, // cropRect가 없어도 true로 명시 가능
val isVideo: Boolean = false,
val videoDurationMs: Long = 0L, // isVideo == true 일 때만 유효 (밀리초)
)
) : Parcelable
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal interface GalleryContract {
val maxSelectionCount: Int = 10,
val showAlbumSelector: Boolean = true,
val editResults: Map<Long, PickedImage> = emptyMap(),
val isAlbumsLoading: Boolean = true,
) {
val isSelectionLimitReached: Boolean
get() = selectedImages.size >= maxSelectionCount
Expand All @@ -28,6 +29,9 @@ internal interface GalleryContract {
data object Initialize : Intent
data class SelectAlbum(val album: GalleryAlbum) : Intent
data class ToggleImageSelection(val image: GalleryImage) : Intent
data class BeginDragSelection(val anchorImage: GalleryImage) : Intent
data class UpdateDragSelectionRange(val rangeImages: List<GalleryImage>) : Intent
data object EndDragSelection : Intent
data class OnEditResult(val pickedImage: PickedImage) : Intent
data object Confirm : Intent
data object Cancel : Intent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ import androidx.paging.compose.itemKey
import io.github.seunghee17.imagepicker.domain.model.GalleryImage
import io.github.seunghee17.imagepicker.presentation.component.TopBarWithCount
import io.github.seunghee17.imagepicker.presentation.utils.DragSelectionState
import io.github.seunghee17.imagepicker.presentation.utils.gridItemKeyAtPosition
import io.github.seunghee17.imagepicker.presentation.utils.computeDragRange
import io.github.seunghee17.imagepicker.presentation.utils.gridItemInfoAtPosition
import io.github.seunghee17.imagepicker.presentation.utils.photoGridDragHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow

/// 갤러리 이미지 그리드 화면 (권한이 허용된 상태에서 표시).
/// 갤러리 이미지 화면 (권한이 허용된 상태에서 표시).

@Composable
internal fun GalleryScreen(
Expand All @@ -61,16 +62,15 @@ internal fun GalleryScreen(
val gridState = rememberLazyGridState()
val autoScrollSpeed = remember { mutableFloatStateOf(0f) }
val currentDragState = remember { mutableStateOf<DragSelectionState?>(null) }
val currentState by rememberUpdatedState(state)
var dropDownExpanded by rememberSaveable { mutableStateOf(false) }

val pagingItems = pagingFlow.collectAsLazyPagingItems()

// 현재 로드된 아이템을 id → GalleryImage 맵으로 캐시 (드래그 선택 조회용)
val itemsById = remember(pagingItems.itemSnapshotList) {
pagingItems.itemSnapshotList.items.associateBy { it.id }
// grid 인덱스 순으로 정렬된, 현재 로드된 이미지 스냅샷 (드래그 range 선택 조회용)
val itemsSnapshot = remember(pagingItems.itemSnapshotList) {
pagingItems.itemSnapshotList.items
}
val currentItemsById by rememberUpdatedState(itemsById)
val currentItemsSnapshot by rememberUpdatedState(itemsSnapshot)

// 화면이 컴포지션을 떠날 때(에디터 진입 등) 잔여 스낵바 제거
DisposableEffect(Unit) {
Expand All @@ -89,21 +89,22 @@ internal fun GalleryScreen(
}
}

// 드래그 중 autoScrollSpeed 값에 따라 그리드를 자동 스크롤
// 드래그 중 autoScrollSpeed 값에 따라 그리드를 자동 스크롤하면서, 손가락이 고정된 채로
// 그리드가 스크롤되어 새로운 아이템이 그 아래로 지나갈 때도 anchor~현재 위치 range를 계속 갱신
LaunchedEffect(gridState) {
snapshotFlow { autoScrollSpeed.floatValue }
.collect { _ ->
while (autoScrollSpeed.floatValue != 0f) {
gridState.scrollBy(autoScrollSpeed.floatValue)
currentDragState.value?.let { dragState ->
gridState.gridItemKeyAtPosition(dragState.offset)?.let { key ->
if (dragState.lastProcessedKey != key &&
currentState.selectedImages.none { it.id == key }
) {
currentItemsById[key]?.let { image ->
onIntent(GalleryContract.Intent.ToggleImageSelection(image))
currentDragState.value = dragState.copy(lastProcessedKey = key)
}
gridState.gridItemInfoAtPosition(dragState.offset)?.let { info ->
if (dragState.lastProcessedIndex != info.index) {
onIntent(
GalleryContract.Intent.UpdateDragSelectionRange(
computeDragRange(dragState.anchorIndex, info.index, currentItemsSnapshot)
)
)
currentDragState.value = dragState.copy(lastProcessedIndex = info.index)
}
}
}
Expand Down Expand Up @@ -167,11 +168,15 @@ internal fun GalleryScreen(
.photoGridDragHandler(
lazyGridState = gridState,
haptics = LocalHapticFeedback.current,
selectedImages = state.selectedImages,
onSelect = { id ->
currentItemsById[id]?.let { image ->
onIntent(GalleryContract.Intent.ToggleImageSelection(image))
}
imagesSnapshot = itemsSnapshot,
onBeginDrag = { image ->
onIntent(GalleryContract.Intent.BeginDragSelection(image))
},
onUpdateRange = { images ->
onIntent(GalleryContract.Intent.UpdateDragSelectionRange(images))
},
onEndDrag = {
onIntent(GalleryContract.Intent.EndDragSelection)
},
autoScrollSpeed = autoScrollSpeed,
autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
Expand All @@ -35,24 +36,6 @@ internal class GalleryScreenViewModel(
showAlbumSelector: Boolean = true,
) : ViewModel() {

// 앨범 선택 상태: 아직 앨범 목록이 로드되지 않은 Pending vs 실제 선택된 Active
private sealed interface AlbumFilter {
data object Pending : AlbumFilter
data class Active(val albumId: String?) : AlbumFilter
}

private val _albumFilter = MutableStateFlow<AlbumFilter>(AlbumFilter.Pending)

/**
* 현재 선택된 앨범의 이미지를 페이지 단위로 방출하는 Flow.
* [GalleryScreen] 에서 [collectAsLazyPagingItems] 로 소비한다.
*/
val pagingFlow: Flow<PagingData<GalleryImage>> = _albumFilter
.filterIsInstance<AlbumFilter.Active>()
.distinctUntilChanged()
.flatMapLatest { filter -> getPagedImages(filter.albumId) }
.cachedIn(viewModelScope)

private val _state = MutableStateFlow(
GalleryContract.State(
maxSelectionCount = maxSelectionCount,
Expand All @@ -64,9 +47,22 @@ internal class GalleryScreenViewModel(
private val _effect = Channel<GalleryContract.Effect>(Channel.BUFFERED)
val effect = _effect.receiveAsFlow()


val pagingFlow: Flow<PagingData<GalleryImage>> = _state
.filter { !it.isAlbumsLoading }
.map { it.selectedAlbum?.id }
.distinctUntilChanged()
.flatMapLatest { albumId -> getPagedImages(albumId) }
.cachedIn(viewModelScope)

private var albumsObserved = false
private var pendingCacheClean = false

// 드래그 range 선택 중에만 쓰이는 임시 상태 (드래그 제스처 시작~종료 스코프)
private var dragBaseline: List<GalleryImage>? = null
private var dragIsDeselecting = false
private var dragLimitSnackbarShown = false

fun handleIntent(intent: GalleryContract.Intent) {
when (intent) {
GalleryContract.Intent.Initialize -> {
Expand All @@ -76,11 +72,12 @@ internal class GalleryScreenViewModel(
}
observeAlbums()
}
is GalleryContract.Intent.SelectAlbum -> {
is GalleryContract.Intent.SelectAlbum ->
_state.update { it.copy(selectedAlbum = intent.album) }
_albumFilter.value = AlbumFilter.Active(intent.album.id)
}
is GalleryContract.Intent.ToggleImageSelection -> toggleSelection(intent.image)
is GalleryContract.Intent.BeginDragSelection -> beginDragSelection(intent.anchorImage)
is GalleryContract.Intent.UpdateDragSelectionRange -> updateDragSelectionRange(intent.rangeImages)
GalleryContract.Intent.EndDragSelection -> endDragSelection()
is GalleryContract.Intent.OnEditResult -> applyEditResult(intent.pickedImage)
GalleryContract.Intent.Confirm -> confirmSelection()
GalleryContract.Intent.Cancel -> cancel()
Expand All @@ -93,13 +90,11 @@ internal class GalleryScreenViewModel(
getAlbums()
.onEach { albums ->
_state.update { current ->
val selectedAlbum = current.selectedAlbum ?: albums.firstOrNull()
current.copy(albums = albums, selectedAlbum = selectedAlbum)
}
val selectedAlbumId = _state.value.selectedAlbum?.id
val nextFilter = AlbumFilter.Active(selectedAlbumId)
if (_albumFilter.value != nextFilter) {
_albumFilter.value = nextFilter
current.copy(
albums = albums,
selectedAlbum = current.selectedAlbum ?: albums.firstOrNull(),
isAlbumsLoading = false,
)
}
}
.launchIn(viewModelScope)
Expand All @@ -125,6 +120,47 @@ internal class GalleryScreenViewModel(
_state.update { it.copy(selectedImages = it.selectedImages + image) }
}

// Google Photos 스타일 드래그 range 선택: 롱프레스한 anchor가 이미 선택되어 있었다면 해제 모드,
// 아니라면 선택 모드로 이번 드래그 제스처의 목표 동작을 고정한다.
private fun beginDragSelection(anchorImage: GalleryImage) {
val current = _state.value
dragBaseline = current.selectedImages
dragIsDeselecting = current.selectedImages.any { it.id == anchorImage.id }
dragLimitSnackbarShown = false
}

// anchor~현재 손가락 위치 사이의 range를 매번 baseline 기준으로 다시 계산한다.
// (증분 누적이 아니므로 손가락을 되돌리면 range 밖으로 나간 항목이 자동으로 원상복구된다)
private fun updateDragSelectionRange(rangeImages: List<GalleryImage>) {
val baseline = dragBaseline ?: return
val rangeIds = rangeImages.map { it.id }.toSet()

val desired = if (dragIsDeselecting) {
baseline.filter { it.id !in rangeIds }
} else {
baseline + rangeImages.filter { image -> baseline.none { it.id == image.id } }
}

val maxCount = _state.value.maxSelectionCount
if (desired.size > maxCount) {
_state.update { it.copy(selectedImages = desired.take(maxCount)) }
if (!dragLimitSnackbarShown) {
dragLimitSnackbarShown = true
viewModelScope.launch {
_effect.send(GalleryContract.Effect.ShowSelectionLimitSnackbar(maxCount))
}
}
} else {
_state.update { it.copy(selectedImages = desired) }
}
}

private fun endDragSelection() {
dragBaseline = null
dragIsDeselecting = false
dragLimitSnackbarShown = false
}

private fun applyEditResult(pickedImage: PickedImage) {
_state.update { current ->
val targetId = current.selectedImages
Expand Down
Loading
Loading