Scroll Picker
사용화면
해당 휠 피커는 연, 월, 일을 선택하는 휠 피커이며 원하는 항목을 사용하도록 커스텀할 수 있다. 예를 들어 맑음, 흐림, 비, 눈, 강풍처럼 날씨를 선택하는 휠 피커를 만들 수도 있다.
코드 설명
- 변수
// 초기값(2025년 3월)
val yearMonth: YearMonth by remember { mutableStateOf(YearMonth.now()) }
// ListState
val yearListState = rememberLazyListState(yearMonth.year - 1900)
val monthListState = rememberLazyListState(yearMonth.monthValue - 1)
// 변수
val density = LocalDensity.current
val threshold = remember { density.run { 20.dp.toPx() } } // 한 칸 높이의 절반
val year by remember { derivedStateOf { (yearListState.firstVisibleItemIndex + if (yearListState.firstVisibleItemScrollOffset >= threshold) 1901 else 1900) } }
val month by remember { derivedStateOf { (monthListState.firstVisibleItemIndex + if (monthListState.firstVisibleItemScrollOffset >= threshold) 2 else 1) } }
• date : 초기값 설정을 위한 변수로써 예시로 사용하는 값은 2025년 3월이다.
- 변수 클래스는 연월을 포함하고 있는 YearMonth를 사용했으며 보여줄 옵션들을 포함하는 클래스면 아무 클래스나 사용하면 된다.
• yearListState : 연도는 선택지를 1900부터 제공할 예정이기 때문에 초기값에서 1900을 빼서 초기 인덱스를 계산해 입력해 준다. 따라서 2025에서 1900을 뺀 125가 초기 인덱스가 되며 인덱스는 0부터 시작되므로 126번째 항목인 2025부터 보이게 된다.
• monthListState : 월은 선택지를 1부터 제공하므로 초기값 3에서 1을 빼 2번째 항목인 3부터 보이게 설정한다.
• density, threshold : 현재 선택된 항목을 변경할 때 사용되는 기준이다.
- 한 칸의 높이를 40.dp로 설정하였기 때문에 그 절반인 20.dp로 설정한다.
- firstVisibleItemScrollOffset이 flaot 타입이므로 density를 사용해 float 타입으로 변환해 둔다.
• year, month : 현재 선택된 옵션을 저장하는 변수이다. 화면에서 다른 색으로 표시할 때 사용하며 매우 빠르게 업데이트될 수 있는 값이므로 derivedStateOf를 사용하여 최적화해 준다.
- 연도의 초기 인덱스는 125였으므로 1900을 더해 2025가 year에 저장되고 스크롤해서 화면에 보이는 첫 번째 아이템의 인덱스가 변할 때마다 새로운 값이 계산되어 저장된다.
- 선택된 항목을 변경하는 기준이 높이의 절반이므로 위의 gif나 영상을 보면 항목 높이의 절반만큼 스크롤하면 선택된 항목이 변경되는 것을 확인할 수 있다.
- Composable
나머지 부분은 디자인이고 주목해서 볼 부분은 LazyColumn이다. state를 선언할 때 LazyList에 대한 state를 선언했으므로 LazyColumn을 사용해 수직 피커로 만들 수도 있고 LazyRow를 사용해 수평 피커도 제작할 수도 있다.
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(0.75f)
) {
LazyColumn(
state = yearListState,
contentPadding = PaddingValues(16.dp, 80.dp),
flingBehavior = rememberSnapFlingBehavior(yearListState),
modifier = Modifier
.weight(1f)
.height(200.dp)
) {
items((1900..2100).toList()) { int ->
val textColor by animateColorAsState(if (int == year) Color.Black else Color(0xFFD0D0D0))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
) {
BasicText(
text = "${int}년",
style = LocalTextStyle.current.copy(
fontSize = 23.sp,
fontWeight = FontWeight.Normal
),
color = { textColor }
)
}
}
}
LazyColumn(
state = monthListState,
contentPadding = PaddingValues(16.dp, 80.dp),
flingBehavior = rememberSnapFlingBehavior(monthListState),
modifier = Modifier
.weight(1f)
.height(200.dp)
) {
items((1..12).toList()) { int ->
val textColor by animateColorAsState(if (int == month) Color.Black else Color(0xFFD0D0D0))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
) {
BasicText(
text = "${int}월",
style = LocalTextStyle.current.copy(
fontSize = 23.sp,
fontWeight = FontWeight.Normal
),
color = { textColor }
)
}
}
}
}
}
• contentPadding : 현재 선택된 항목의 위아래에 어떤 항목이 있는지 보여주기 위해서 상하단에 공간을 마련해야 한다. 필자의 한 칸 높이는 40.dp이고 각각 두 개씩 보이게 하기 위해 상하단에 80.dp의 패딩을 주었다.
• flingBehavior : rememberSnapFlingBehavior를 사용해서 스크롤하다 놓으면 가장 가까운 값으로 자동으로 스냅 되게 하였다. 이 함수는 필자가 제작한 함수가 아닌 compose foundation에 포함된 함수이다.
• modifier : 상단 2칸 중앙 1칸 하단 2칸 해서 총 5칸이므로 높이를 200.dp로 설정한다.
• items : 옵션으로 보여줄 항목을 리스트로 만들어 List 버전의 items의 파라미터로 입력해 준다.
• textColor, BasicText : 색상 변환을 부드럽게 해주는 방법이며 animateColorAsState를 사용할 때 BasicText와 함께 사용하면 재구성 최적화에 도움이 된다. 본인 취향에 맞게 사용하면 된다.
- 연과 일은 항목 수가 변하지 않으므로 직접 리스트로 만들어서 입력해도 되지만 일은 월에 따라 항목 수가 변하므로 lengthOfMonth 함수를 사용해서 계산해서 입력해 준다.
마치며
최대한 발생할 수 있는 상황을 예측하면서 예외 처리를 하였지만 미처 예상하지 못한 상황이 있을 수 있기에 그런 상황을 발견한다면 말씀해 주시면 감사하겠습니다. 그 외에도 궁금한 점이나 피드백은 댓글이나 카톡으로 연락해 주시면 최대한 빠르게 답변해 드리겠습니다.