티스토리 뷰
메인화면


왼쪽의 UI를 오른쪽과 같이 3등분으로 분리하여 UI를 구현하였습니다.
@Composable
fun ShoesMainScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.background(ShoesBlack)
) {
/** 상단 타이틀 영역 **/
ShoesMainHeader()
/** 텝 메뉴, 뷰페이져 **/
ShoesMainBody(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
/** 하단 메뉴 **/
ShoesMainFooter()
}
}
1) Header
@Composable
fun ShoesMainHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 36.dp, start = 16.dp, end = 16.dp)
) {
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "logo",
modifier = Modifier.size(width = 61.dp, height = 34.dp)
)
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(id = R.drawable.ic_menu),
contentDescription = "menu",
modifier = Modifier.size(34.dp)
)
Spacer(modifier = Modifier.width(17.dp))
Image(
painter = painterResource(id = R.drawable.ic_basket),
contentDescription = "basket",
modifier = Modifier.size(34.dp)
)
}
}

특별한 것 없이 Row를 활용하여 아이콘들을 배치하였습니다.
2) Body
@Composable
fun ShoesMainBody(modifier: Modifier) {
LazyColumn(
contentPadding = PaddingValues(top = 56.dp, bottom = 100.dp),
modifier = modifier
) {
item { ShoesTabMenu() }
item { Spacer(modifier = Modifier.height(48.dp)) }
item { ShoesViewPager() }
}
}

바디에서는 텝 메뉴와 ViewPager영역을 분리하여 구현하였습니다.
@Composable
fun ShoesTabMenu() {
val state = remember { mutableStateOf(0) }
val list = listOf("Basketball", "Running", "Training")
Row(
horizontalArrangement = Arrangement.spacedBy(22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
) {
list.forEachIndexed { index, it ->
Text(
text = it,
fontSize = 28.sp,
style = shoesTextStyle(),
color = if (index == state.value) ShoesYellow else Color.White,
modifier = Modifier
.clickable(
indication = null,
interactionSource = MutableInteractionSource()
) {
state.value = index
}
)
}
}
}
state는 처음에는 0을 저장하고 있고 Text를 클릭하여 해당하는 Index의 값으로 변경되게 됩니다.
state의 값이 변경되면서 index == state.value의 값도 변경되게 됩니다.
이전에 선택되었던 아이템은 Color.White로 변경되고 새로 선택된 아이템은 White에서 ShoesYellow 색상으로 변경됩니다.
이렇게 구현하면 단순히 색상만 변경이 됩니다.
val colorState by animateColorAsState(
targetValue = if (index == state.value) ShoesYellow else Color.White,
animationSpec = tween(durationMillis = 250, easing = LinearEasing)
)
Text(
text = it,
fontSize = 28.sp,
style = shoesTextStyle(),
color = colorState,
modifier = Modifier
.clickable(
indication = null,
interactionSource = MutableInteractionSource()
) {
state.value = index
}
)
조금 더 자연스럽게 색상을 변경하기 위해 animateColorAsState를 사용합니다.
animationSpec에서 준 효과에 따라 애니메이션 동작을 하며 색상을 변경하게 됩니다.
그다음 color의 값에 만든 colorState를 사용하면 적용 완료입니다.
효과를 보기 위해 1000으로 시간은 늘려서 실행하였습니다.
implementation "com.google.accompanist:accompanist-pager:0.20.1"
implementation "com.google.accompanist:accompanist-pager-indicators:0.20.1"
implementation "androidx.compose.ui:ui-util:$compose_version"
ViewPager를 사용하기 위해 위의 내용을 추가합니다.
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ShoesViewPager() {
val shoesImageList = listOf(
R.drawable.img_card_1, R.drawable.img_card_2, R.drawable.img_card_3,
R.drawable.img_card_4, R.drawable.img_card_5
)
HorizontalPager(
count = shoesImageList.size + 1,
contentPadding = PaddingValues(start = 27.dp, end = 85.dp)
) { page ->
val context = LocalContext.current
val pageOffset = calculateCurrentOffsetForPage(page).absoluteValue
if (shoesImageList.size > page) {
ShoesCard(
imageRes = shoesImageList[page],
modifier = Modifier
.graphicsLayer { pagerSettings(pageOffset) }
) {
context.startActivity(
Intent(context, ShoesDetailActivity::class.java).also {
it.putExtra(ShoesDetailActivity.Index, page)
}
)
}
} else {
Button(
onClick = { },
colors = ButtonDefaults.buttonColors(
backgroundColor = ShoesYellow,
contentColor = ShoesBlack
),
modifier = Modifier
.size(width = 218.dp, height = 300.dp)
.clip(RoundedCornerShape(33.dp))
.graphicsLayer { pagerSettings(pageOffset) }
) {
Text(text = "더 보기", style = shoesTextStyle())
}
}
}
}
HorizontalPager는 count 안에 리스트의 사이즈를 넣어주면 되는데 이번에 저는 마지막에 버튼을 넣어보기 위해 size+1을 넣었습니다.
contentPadding은 기본적으로 HorizontalPager의 content는 가로 영역을 다 차지하고 있어서 PaddingValues를 설정함으로써 content의 크기를 줄이고 그러면서 다음 content가 보이게 됩니다.
HorizontalPager안의 Item들의 Modifier에서는 graphicsLayer를 사용할 수 있는데 graphicsLayer는 Scroll 시 content들의 모습을 설정할 수 있습니다.
fun GraphicsLayerScope.pagerSettings(pageOffset: Float) {
// 크기 조절
lerp(
start = 0.85f,
stop = 1f,
fraction = 1f - pageOffset.coerceIn(0f, 1f)
).also { scale ->
scaleX = scale
scaleY = scale
}
// 투명도 조절
alpha = lerp(
start = 0.2f,
stop = 1f,
fraction = 1f - pageOffset.coerceIn(0f, 1f)
)
}
start는 준비 중인 상태로 초기화면 기준 2번째 아이템입니다.
stop은 메인으로 보여주는 화면으로 초기화면 기준 1번째 아이템입니다.
각 값들을 변경해가면서 원하는 모양을 만들어 나가면 됩니다.
@Composable
fun ShoesCard(
@DrawableRes imageRes: Int,
modifier: Modifier = Modifier,
onClickListener: () -> Unit
) {
Card(
shape = RoundedCornerShape(40.dp),
modifier = modifier
) {
ConstraintLayout(
modifier = Modifier
.size(width = 255.dp, height = 355.dp)
) {
val (background, group, title, price, image, button) = createRefs()
Box(
modifier = Modifier
.fillMaxHeight()
.width(227.dp)
.clip(RoundedCornerShape(40.dp))
.background(
Color.White
)
.constrainAs(background) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}
)
Text(
text = "NIKE AIR",
style = shoesTextStyle(),
fontSize = 18.sp,
color = ShoesBlack,
modifier = Modifier
.padding(top = 24.dp, start = 24.dp)
.constrainAs(group) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}
)
Text(
text = "AIR JORDAN 1 MID SE GC",
style = shoesTextStyle(),
color = ShoesBlack,
modifier = Modifier
.padding(start = 24.dp)
.constrainAs(title) {
top.linkTo(group.bottom)
start.linkTo(group.start)
}
)
Image(
painter = painterResource(id = imageRes),
contentDescription = "shoes",
modifier = Modifier
.constrainAs(image) {
top.linkTo(parent.top)
end.linkTo(parent.end)
}
.padding(top = 88.dp)
.size(height = 225.dp, width = 224.dp)
)
Text(
text = "$856",
style = shoesTextStyle(),
color = ShoesBlack,
modifier = Modifier
.padding(start = 24.dp)
.constrainAs(price) {
top.linkTo(title.bottom)
start.linkTo(title.start)
}
)
Box(
modifier = Modifier
.size(width = 83.dp, height = 76.dp)
.clip(RoundedCornerShape(topStart = 40.dp, bottomEnd = 40.dp))
.background(ShoesYellow)
.constrainAs(button) {
bottom.linkTo(parent.bottom)
end.linkTo(background.end)
}
.clickable {
onClickListener()
}
) {
Image(
painter = painterResource(id = R.drawable.ic_plus),
contentDescription = "plus",
modifier = Modifier
.align(Alignment.Center)
.size(34.dp)
)
}
}
}
}
카드 아이템은 배치를 편하게 하기 위하여 ConstraintLayout을 이용하였습니다.
3) Footer
@Composable
fun ShoesMainFooter() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(95.dp)
.clip(RoundedCornerShape(topStart = 40.dp, topEnd = 40.dp))
.background(ShoesYellow)
) {
Image(
painter = painterResource(id = R.drawable.ic_home),
contentDescription = "home",
modifier = Modifier
.padding(start = 33.dp)
.size(34.dp)
.align(Alignment.CenterStart)
)
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(ShoesBlack)
.padding(13.dp)
.clip(CircleShape)
.background(ShoesYellow)
.padding(3.dp)
.clip(CircleShape)
.background(ShoesBlack)
.padding(3.dp)
.clip(CircleShape)
.background(ShoesYellow)
.padding(3.dp)
.clip(CircleShape)
.background(ShoesBlack)
.padding(3.dp)
.clip(CircleShape)
.background(ShoesYellow)
.align(Alignment.Center)
)
Image(
painter = painterResource(id = R.drawable.ic_user),
contentDescription = "user",
modifier = Modifier
.padding(end = 33.dp)
.size(34.dp)
.align(Alignment.CenterEnd)
)
}
}
다른 건 특별한 건 없고 단순 아이콘 배치입니다.
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(ShoesBlack)
.padding(13.dp)
.clip(CircleShape)
.background(ShoesYellow)
.padding(3.dp)
.clip(CircleShape)
.background(ShoesBlack)
.padding(3.dp)
.clip(CircleShape)
.background(ShoesYellow)
.padding(3.dp)
.clip(CircleShape)
.background(ShoesBlack)
.padding(3.dp)
.clip(CircleShape)
.background(ShoesYellow)
.align(Alignment.Center)
)
Compose에서는 위와 같이 padding과 background를 어떻게 적용시키는지에 따라 모양을 만들 수 있습니다.
위의 코드가 효율적이다라고는 말하지 못하겠지만 가능하다는 건 알았는 데 사용할 일이 좀처럼 없었어서 한번 사용해 봤습니다.
상세화면


@Composable
fun ShoesDetailScreen() {
Box(
modifier = Modifier
.fillMaxSize()
.background(ShoesBlack)
) {
ShoesDetailBody()
ShoesDetailHeader()
}
}
@Composable
fun ShoesDetailHeader() {
val activity = LocalContext.current as Activity
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(top = 36.dp, start = 23.dp, end = 20.dp)
) {
Box(
modifier = Modifier
.size(34.dp)
.clip(RoundedCornerShape(7.dp))
.background(Color.White)
) {
Image(
painter = painterResource(id = R.drawable.ic_back),
contentDescription = "back",
modifier = Modifier
.align(Alignment.Center)
.clickable(indication = null, interactionSource = MutableInteractionSource()) {
activity.finish()
}
)
}
Image(
painter = painterResource(id = R.drawable.ic_heart),
contentDescription = "heart",
modifier = Modifier
.size(34.dp)
)
}
}
상세화면에서는 Header와 Body 두 파트로 나누었으며 동일하게 Header의 경우에는 아이콘 배치 밖에 없습니다.
@Composable
fun ShoesDetailBody() {
val color = remember {
mutableStateOf(Color(0xFF855F55))
}
Box(modifier = Modifier.fillMaxSize()) {
ShoesImageInfo(color.value)
ShoesDetailInfo(color = color, modifier = Modifier.align(Alignment.BottomCenter))
}
}
Body에서는 다시 이미지 영역과 그 아래 영역으로 나누어집니다.
@OptIn(ExperimentalPagerApi::class, ExperimentalAnimationApi::class)
@Composable
fun ShoesImageInfo(color: Color) {
ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
val (shoesImage, circle, nickname, indicator) = createRefs()
AnimatedContent(
targetState = color,
transitionSpec = {
fadeIn() + slideIn(initialOffset = { fullSize ->
IntOffset(
fullSize.width / 2,
-fullSize.height / 2
)
}) with fadeOut()
},
modifier = Modifier
.size(450.dp)
.constrainAs(circle) {
top.linkTo(parent.top, (-73).dp)
end.linkTo(parent.end, (-126).dp)
}
) { selectColor ->
Icon(
painter = painterResource(id = R.drawable.ic_circle),
contentDescription = "circle",
tint = selectColor,
)
}
Text(
text = "NIKE AIR",
style = shoesTextStyle(),
fontSize = 130.sp,
modifier = Modifier
.padding(top = 180.dp)
.constrainAs(nickname) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
val pagerState = rememberPagerState()
val listSize = getImageList().size
HorizontalPager(
count = listSize,
state = pagerState,
modifier = Modifier
.constrainAs(shoesImage) {
top.linkTo(parent.top, 80.dp)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
.height(325.dp)
) {
Image(
painter = painterResource(id = getImage(color)),
contentDescription = "shoes",
modifier = Modifier
.size(width = 316.dp, height = 325.dp)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.constrainAs(indicator) {
top.linkTo(shoesImage.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
) {
(0 until listSize).forEach {
val isCurrentPosition = pagerState.currentPage == it
val size by animateDpAsState(targetValue = if (isCurrentPosition) 20.dp else 6.dp)
Box(
modifier = Modifier
.clip(RoundedCornerShape(3.dp))
.height(6.dp)
.width(size)
.background(if (isCurrentPosition) Color.White else Color(0xFF66696B))
)
}
}
}
}
우선 전체 코드입니다.
이 부분에서 애니메이션 적용하면서 구현한 부분은 2가지입니다.
AnimatedContent(
targetState = color,
transitionSpec = {
fadeIn() + slideIn(initialOffset = { fullSize ->
IntOffset(
fullSize.width / 2,
-fullSize.height / 2
)
}) with fadeOut()
},
modifier = Modifier
.size(450.dp)
.constrainAs(circle) {
top.linkTo(parent.top, (-73).dp)
end.linkTo(parent.end, (-126).dp)
}
) { selectColor ->
Icon(
painter = painterResource(id = R.drawable.ic_circle),
contentDescription = "circle",
tint = selectColor,
)
}
첫 번째로 위의 gif에서 색상이 변경되면 원이 움직이는 걸 알 수 있습니다.
AnimatedContent로 감싸서 구현을 하게 되면 안에 설정한 tartgetState의 값이 변경될 때 애니메이션 동작을 수행하게 됩니다.
transitionSpec은 다음과 같은 형식으로 작성해야 합니다.
EnterTransition with ExitTransition
베이스로 FadeIn으로 시작하여 FadeOut으로 종료함으로써 투명도가 변화면서 나왔다 들어갔다 합니다.
추가로 우상단에서 좌하단으로 움직이는 효과를 주고 싶어서 EnterTransition에 slideIn의 값을 추가하였습니다.
val pagerState = rememberPagerState()
val listSize = getImageList().size
HorizontalPager(
count = listSize,
state = pagerState,
modifier = Modifier
.constrainAs(shoesImage) {
top.linkTo(parent.top, 80.dp)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
.height(325.dp)
) {
Image(
painter = painterResource(id = getImage(color)),
contentDescription = "shoes",
modifier = Modifier
.size(width = 316.dp, height = 325.dp)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.constrainAs(indicator) {
top.linkTo(shoesImage.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
) {
(0 until listSize).forEach {
val isCurrentPosition = pagerState.currentPage == it
val size by animateDpAsState(targetValue = if (isCurrentPosition) 20.dp else 6.dp)
Box(
modifier = Modifier
.clip(RoundedCornerShape(3.dp))
.height(6.dp)
.width(size)
.background(if (isCurrentPosition) Color.White else Color(0xFF66696B))
)
}
}
두 번 째는 HorizontalPager의 Position이 변경되었을 때 Indicator의 애니메이션 효과입니다.
Indicator는 애니메이션 연습도 할 겸 직접 구현을 하였습니다.
조금 더 자연스럽게 움직이는 방식은 더 공부를 해봐야겠습니다.
이 UI에서 Indicator는 선택된 경우 막대형으로 크기가 다르게 설정되어있습니다.
Pager의 선택된 값과 Indicator의 Index값을 비교해서 크기를 변경을 해주었습니다.
animateDpAsState를 이용하면 isCurrentPosition의 값이 변경되었을 때 사이즈가 자연스럽게 변경이 됩니다.
이 외 아래 정보들은 색상 State 값 변경 외에는 단순 배치라서 생략하도록 하겠습니다.
'안드로이드 > 디자인' 카테고리의 다른 글
Compose 디자인 : 게임 화면 (0) | 2022.08.30 |
---|
- Total
- Today
- Yesterday
- column
- Compose BottomSheetDialog
- compose
- Android Compose
- WebView
- Row
- 안드로이드
- WorkManager
- Android
- Compose Naver Map
- Compose 네이버 지도
- 안드로이드 구글 지도
- Duplicate class found error
- Compose ModalBottomSheetLayout
- Fast api
- Compose BottomSheetScaffold
- 포켓몬 도감
- Worker
- Compose 네이버 지도 api
- Duplicate class fond 에러
- Compose BottomSheet
- LazyColumn
- Compose QRCode Scanner
- Compose MotionLayout
- Retrofit
- Gradient
- Compose ConstraintLayout
- Kotlin
- 웹뷰
- Pokedex
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 29 | 30 |