Compose Viewpager

2023. 11. 1. 18:51개발/Android

주요 기능

  • 선택한 항목이 컨테이너 중앙에 배치됩니다.
  • 호출기는 수평 또는 수직으로 스크롤할 수 있습니다.
  • 항목은 컨테이너 크기의 일부를 차지할 수 있으므로 인접한 항목이 측면에 정점에 있을 수 있습니다.
  • 사용자가 첫 번째 또는 마지막 항목을 지나 스크롤할 수 있도록 항목을 초과하는 것이 가능하지만 호출기는 항목을 다시 중앙에 배치하도록 재설정됩니다.
  • 항목 간의 구분을 지정할 수 있습니다.
  • 처음에 어떤 항목이 중앙에 배치될지 나타낼 수 있습니다.

Pager

@Composable
fun <T : Any> Pager(
    // 1
    items: List<T>,
    // 2
    modifier: Modifier = Modifier,
    // 3
    orientation: Orientation = Orientation.Horizontal,
    // 4
    initialIndex: Int = 0,
    // 5
    /*@FloatRange(from = 0.0, to = 1.0)*/
    itemFraction: Float = 1f,
    // 6
    itemSpacing: Dp = 0.dp,
    /*@FloatRange(from = 0.0, to = 1.0)*/
    // 7
    overshootFraction: Float = .5f,
    // 8
    onItemSelect: (T) -> Unit = {},
    // 9
    contentFactory: @Composable (T) -> Unit,
) {
    // 10
    require(initialIndex in 0..items.lastIndex) { "Initial index out of bounds" }
    require(itemFraction > 0f && itemFraction <= 1f) { "Item fraction must be in the (0f, 1f] range" }
    require(overshootFraction > 0f && itemFraction <= 1f) { "Overshoot fraction must be in the (0f, 1f] range" }
    
    // TODO: composition
}

  1. 리스트 가져옴
  1. modifier 적용
  1. 스크롤 방향 수평으로
  1. 중간에 위치할 초기값 position
  1. 항목 컨테이너의 전체 너비 또는 높이의 비율
  1. 간격
  1. 첫 항목/마지막 항목이 어느 정도 위치에서 스크롤 될 수 있는지 상위 너비 / 높이
  1. 항목 선택 콜백
  1. 항목 빌드 (contentFactory)
  1. pager 이 범위 내에 있는지, 인덱스가 범위 내에 있는지

스크롤 메서드

private fun Constraints.dimension(orientation: Orientation) = when (orientation) {
    Orientation.Horizontal -> maxWidth
    Orientation.Vertical -> maxHeight
}
  • 스크롤 방향을 기준으로 최대 크기 반환

Constraints 함수

private fun Constraints.toLooseConstraints(
    orientation: Orientation,
    itemFraction: Float,
): Constraints {
    val dimension = dimension(orientation)
    val adjustedDimension = (dimension * itemFraction).roundToInt()
    return when (orientation) {
        Orientation.Horizontal -> copy(
            minWidth = adjustedDimension,
            maxWidth = adjustedDimension,
            minHeight = 0,
        )
        Orientation.Vertical -> copy(
            minWidth = 0,
            minHeight = adjustedDimension,
            maxHeight = adjustedDimension,
        )
    }
}
  • 스크롤 방향이 아닌 최소 크기가 0으로 설정된 복사본을 만들고 , 스크롤 방향에서는 최소 크기를 최대 크기와 동일하게 설정합니다.

Parcelables 베이스 함수

private fun List<Placeable>.getSize(
    orientation: Orientation,
    dimension: Int,
): IntSize = when (orientation) {
    Orientation.Horizontal -> IntSize(
        dimension,
        maxByOrNull { it.height }?.height ?: 0
    )
    Orientation.Vertical -> IntSize(
        maxByOrNull { it.width }?.width ?: 0,
        dimension
    )
}
  • 스크롤 방향에 따라 크기를 가져옴
  • 가로로 스크롤하는 경우 항목의 최대 높이
  • 그렇지 않고 수직으로 스크롤하는 경우 최대 너비를 계산

VelocityTracker

private fun PointerInputChange.calculateDragChange(orientation: Orientation) =
    when (orientation) {
        Orientation.Horizontal -> positionChange().x
        Orientation.Vertical -> positionChange().y
    }
  • 스크롤 축을 기반으로 계산된 속도를 제공

PagerState

private class PagerState {
    var currentIndex by mutableStateOf(0)
    var numberOfItems by mutableStateOf(0)
    var itemFraction by mutableStateOf(0f)
    var overshootFraction by mutableStateOf(0f)
    var itemSpacing by mutableStateOf(0f)
    var itemDimension by mutableStateOf(0)
    var orientation by mutableStateOf(Orientation.Horizontal)
    var scope: CoroutineScope? by mutableStateOf(null)
    var listener: (Int) -> Unit by mutableStateOf({})
    val dragOffset = Animatable(0f)
    
    // TODO: more to come    
}

@Composable
private fun rememberPagerState(): PagerState = remember {
    PagerState()
}

호출

@Composable
fun <T : Any> Pager(
    items: List<T>,
    modifier: Modifier = Modifier,
    orientation: Orientation = Orientation.Horizontal,
    initialIndex: Int = 0,
    /*@FloatRange(from = 0.0, to = 1.0)*/
    itemFraction: Float = 1f,
    itemSpacing: Dp = 0.dp,
    /*@FloatRange(from = 0.0, to = 1.0)*/
    overshootFraction: Float = .5f,
    onItemSelect: (T) -> Unit = {},
    contentFactory: @Composable (T) -> Unit,
) {
    require(initialIndex in 0..items.lastIndex) { "Initial index out of bounds" }
    require(itemFraction > 0f && itemFraction <= 1f) { "Item fraction must be in the (0f, 1f] range" }
    require(overshootFraction > 0f && itemFraction <= 1f) { "Overshoot fraction must be in the (0f, 1f] range" }
    val scope = rememberCoroutineScope()
    val state = rememberPagerState()
    state.currentIndex = initialIndex
    state.numberOfItems = items.size
    state.itemFraction = itemFraction
    state.overshootFraction = overshootFraction
    state.itemSpacing = with(LocalDensity.current) { itemSpacing.toPx() }
    state.orientation = orientation
    state.listener = { index -> onItemSelect(items[index]) }
    state.scope = scope
   
    // TODO: actual composition
}

전체 코드

@Composable
fun <T : Any> Pager(
    items: List<T>,
    modifier: Modifier = Modifier,
    orientation: Orientation = Orientation.Horizontal,
    initialIndex: Int = 0,
    /*@FloatRange(from = 0.0, to = 1.0)*/
    itemFraction: Float = 1f,
    itemSpacing: Dp = 0.dp,
    /*@FloatRange(from = 0.0, to = 1.0)*/
    overshootFraction: Float = .5f,
    onItemSelect: (T) -> Unit = {},
    contentFactory: @Composable (T) -> Unit,
) {
    require(initialIndex in 0..items.lastIndex) { "Initial index out of bounds" }
    require(itemFraction > 0f && itemFraction <= 1f) { "Item fraction must be in the (0f, 1f] range" }
    require(overshootFraction > 0f && itemFraction <= 1f) { "Overshoot fraction must be in the (0f, 1f] range" }
    val scope = rememberCoroutineScope()
    val state = rememberPagerState()
    state.currentIndex = initialIndex
    state.numberOfItems = items.size
    state.itemFraction = itemFraction
    state.overshootFraction = overshootFraction
    state.itemSpacing = with(LocalDensity.current) { itemSpacing.toPx() }
    state.orientation = orientation
    state.listener = { index -> onItemSelect(items[index]) }
    state.scope = scope

    Layout(
        content = {
            items.map { item ->
                Box(
                    modifier = when (orientation) {
                        Orientation.Horizontal -> Modifier.fillMaxWidth()
                        Orientation.Vertical -> Modifier.fillMaxHeight()
                    },
                    contentAlignment = Alignment.Center,
                ) {
                    contentFactory(item)
                }
            }
        },
        modifier = modifier
            .clipToBounds()
            .then(state.inputModifier),
    ) { measurables, constraints ->
        val dimension = constraints.dimension(orientation)
        val looseConstraints = constraints.toLooseConstraints(orientation, state.itemFraction)
        val placeables = measurables.map { measurable -> measurable.measure(looseConstraints) }
        val size = placeables.getSize(orientation, dimension)
        val itemDimension = (dimension * state.itemFraction).roundToInt()
        state.itemDimension = itemDimension
        val halfItemDimension = itemDimension / 2
        layout(size.width, size.height) {
            val centerOffset = dimension / 2 - halfItemDimension
            val dragOffset = state.dragOffset.value
            val roundedDragOffset = dragOffset.roundToInt()
            val spacing = state.itemSpacing.roundToInt()
            val itemDimensionWithSpace = itemDimension + state.itemSpacing
            val first = ceil(
                (dragOffset -itemDimension - centerOffset) / itemDimensionWithSpace
            ).toInt().coerceAtLeast(0)
            val last = ((dimension + dragOffset - centerOffset) / itemDimensionWithSpace).toInt()
                .coerceAtMost(items.lastIndex)
            for (i in first..last) {
                val offset = i * (itemDimension + spacing) - roundedDragOffset + centerOffset
                placeables[i].place(
                    x = when (orientation) {
                        Orientation.Horizontal -> offset
                        Orientation.Vertical -> 0
                    },
                    y = when (orientation) {
                        Orientation.Horizontal -> 0
                        Orientation.Vertical -> offset
                    }
                )
            }
        }
    }

    LaunchedEffect(key1 = items, key2 = initialIndex) {
        state.snapTo(initialIndex)
    }
}

@Composable
private fun rememberPagerState(): PagerState = remember { PagerState() }

private fun Constraints.dimension(orientation: Orientation) = when (orientation) {
    Orientation.Horizontal -> maxWidth
    Orientation.Vertical -> maxHeight
}

private fun Constraints.toLooseConstraints(
    orientation: Orientation,
    itemFraction: Float,
): Constraints {
    val dimension = dimension(orientation)
    return when (orientation) {
        Orientation.Horizontal -> copy(
            minWidth = (dimension * itemFraction).roundToInt(),
            maxWidth = (dimension * itemFraction).roundToInt(),
            minHeight = 0,
        )
        Orientation.Vertical -> copy(
            minWidth = 0,
            minHeight = (dimension * itemFraction).roundToInt(),
            maxHeight = (dimension * itemFraction).roundToInt(),
        )
    }
}

private fun List<Placeable>.getSize(
    orientation: Orientation,
    dimension: Int,
): IntSize {
    return when (orientation) {
        Orientation.Horizontal -> IntSize(
            dimension,
            maxByOrNull { it.height }?.height ?: 0
        )
        Orientation.Vertical -> IntSize(
            maxByOrNull { it.width }?.width ?: 0,
            dimension
        )
    }
}

private class PagerState {
    var currentIndex by mutableStateOf(0)
    var numberOfItems by mutableStateOf(0)
    var itemFraction by mutableStateOf(0f)
    var overshootFraction by mutableStateOf(0f)
    var itemSpacing by mutableStateOf(0f)
    var itemDimension by mutableStateOf(0)
    var orientation by mutableStateOf(Orientation.Horizontal)
    var scope: CoroutineScope? by mutableStateOf(null)
    var listener: (Int) -> Unit by mutableStateOf({})
    val dragOffset = Animatable(0f)

    private val animationSpec = SpringSpec<Float>(
        dampingRatio = Spring.DampingRatioLowBouncy,
        stiffness = Spring.StiffnessLow,
    )

    suspend fun snapTo(index: Int) {
        dragOffset.snapTo(index.toFloat() * (itemDimension + itemSpacing))
    }

    val inputModifier = Modifier.pointerInput(numberOfItems) {
        fun itemIndex(offset: Int): Int = (offset / (itemDimension + itemSpacing)).roundToInt()
            .coerceIn(0, numberOfItems - 1)

        fun updateIndex(offset: Float) {
            val index = itemIndex(offset.roundToInt())
            if (index != currentIndex) {
                currentIndex = index
                listener(index)
            }
        }

        fun calculateOffsetLimit(): OffsetLimit {
            val dimension = when (orientation) {
                Orientation.Horizontal -> size.width
                Orientation.Vertical -> size.height
            }
            val itemSideMargin = (dimension - itemDimension) / 2f
            return OffsetLimit(
                min = -dimension * overshootFraction + itemSideMargin,
                max = numberOfItems * (itemDimension + itemSpacing) - (1f - overshootFraction) * dimension + itemSideMargin,
            )
        }

        forEachGesture {
            awaitPointerEventScope {
                val tracker = VelocityTracker()
                val decay = splineBasedDecay<Float>(this)
                val down = awaitFirstDown()
                val offsetLimit = calculateOffsetLimit()
                val dragHandler = { change: PointerInputChange ->
                    scope?.launch {
                        val dragChange = change.calculateDragChange(orientation)
                        dragOffset.snapTo(
                            (dragOffset.value - dragChange).coerceIn(
                                offsetLimit.min,
                                offsetLimit.max
                            )
                        )
                        updateIndex(dragOffset.value)
                    }
                    tracker.addPosition(change.uptimeMillis, change.position)
                }
                when (orientation) {
                    Orientation.Horizontal -> horizontalDrag(down.id, dragHandler)
                    Orientation.Vertical -> verticalDrag(down.id, dragHandler)
                }
                val velocity = tracker.calculateVelocity(orientation)
                scope?.launch {
                    var targetOffset = decay.calculateTargetValue(dragOffset.value, -velocity)
                    val remainder = targetOffset.toInt().absoluteValue % itemDimension
                    val extra = if (remainder > itemDimension / 2f) 1 else 0
                    val lastVisibleIndex =
                        (targetOffset.absoluteValue / itemDimension.toFloat()).toInt() + extra
                    targetOffset = (lastVisibleIndex * (itemDimension + itemSpacing) * targetOffset.sign)
                        .coerceIn(0f, (numberOfItems - 1).toFloat() * (itemDimension + itemSpacing))
                    dragOffset.animateTo(
                        animationSpec = animationSpec,
                        targetValue = targetOffset,
                        initialVelocity = -velocity
                    ) {
                        updateIndex(value)
                    }
                }
            }
        }
    }

    data class OffsetLimit(
        val min: Float,
        val max: Float,
    )
}

private fun VelocityTracker.calculateVelocity(orientation: Orientation) = when (orientation) {
    Orientation.Horizontal -> calculateVelocity().x
    Orientation.Vertical -> calculateVelocity().y
}

private fun PointerInputChange.calculateDragChange(orientation: Orientation) =
    when (orientation) {
        Orientation.Horizontal -> positionChange().x
        Orientation.Vertical -> positionChange().y
    }

예시

val items = listOf(
    Color.Red,
    Color.Blue,
    Color.Green,
    Color.Yellow,
    Color.Cyan,
    Color.Magenta
)

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppTheme {
                Surface(color = MaterialTheme.colors.background) {
                    Column(
                        Modifier.fillMaxSize().padding(16.dp),
                    ) {
                        Spacer(modifier = Modifier.height(32.dp))
                        Pager(
                            items = items,
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(256.dp),
                            itemFraction = .75f,
                            overshootFraction = .75f,
                            initialIndex = 3,
                            itemSpacing = 16.dp,
                            contentFactory = { item ->
                                Box(
                                    modifier = Modifier
                                        .fillMaxSize()
                                        .background(item),
                                    contentAlignment = Alignment.Center
                                ) {
                                    Text(
                                        text = item.toString(),
                                        modifier = Modifier.padding(all = 16.dp),
                                        style = MaterialTheme.typography.h6,
                                    )
                                }
                            }
                        )

                        Spacer(modifier = Modifier.height(32.dp))

                        Pager(
                            items = items,
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(256.dp),
                            orientation = Orientation.Vertical,
                            itemFraction = .8f,
                            itemSpacing = 16.dp,
                            contentFactory = { item ->
                                Box(
                                    modifier = Modifier
                                        .fillMaxSize()
                                        .background(item),
                                    contentAlignment = Alignment.Center
                                ) {
                                    Text(
                                        text = item.toString(),
                                        modifier = Modifier.padding(all = 16.dp),
                                        style = MaterialTheme.typography.h6,
                                    )
                                }
                            }
                        )
                    }
                }
            }
        }
    }
}


Uploaded by N2T

'개발 > Android' 카테고리의 다른 글

Android 에서 TDD를 습관화하기  (0) 2023.11.30
Useful modifier  (0) 2023.11.30
Avoiding recomposition  (1) 2023.11.01
UI Testing (with Jetpack Compose)  (0) 2023.10.23
자주 까먹는 compose modifiers  (0) 2023.10.23