주요 기능
- 선택한 항목이 컨테이너 중앙에 배치됩니다.
- 호출기는 수평 또는 수직으로 스크롤할 수 있습니다.
- 항목은 컨테이너 크기의 일부를 차지할 수 있으므로 인접한 항목이 측면에 정점에 있을 수 있습니다.
- 사용자가 첫 번째 또는 마지막 항목을 지나 스크롤할 수 있도록 항목을 초과하는 것이 가능하지만 호출기는 항목을 다시 중앙에 배치하도록 재설정됩니다.
- 항목 간의 구분을 지정할 수 있습니다.
- 처음에 어떤 항목이 중앙에 배치될지 나타낼 수 있습니다.
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
}
- 리스트 가져옴
- modifier 적용
- 스크롤 방향 수평으로
- 중간에 위치할 초기값 position
- 항목 컨테이너의 전체 너비 또는 높이의 비율
- 간격
- 첫 항목/마지막 항목이 어느 정도 위치에서 스크롤 될 수 있는지 상위 너비 / 높이
- 항목 선택 콜백
- 항목 빌드 (contentFactory)
- 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