티스토리 뷰

728x90

이번에 작업한 UI 출처입니다.

https://dribbble.com/shots/6289865-AXE-Game-Platform?utm_source=pinterest&utm_campaign=pinterest_shot&utm_content=AXE%E4%B8%A8Game+Platform&utm_medium=Social_Share

@Composable
fun GameScreen() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(getGameBlack())
    ) {
        LazyColumn(
            contentPadding = PaddingValues(bottom = 70.dp),
            modifier = Modifier.fillMaxWidth()
        ) {
            /** 베너 영역 **/
            item { GameHeader() }
            /** 메인 컨텐츠 **/
            item { GameBody() }
        }
        /** 하단바 **/
        GameFooter(modifier = Modifier.align(Alignment.BottomCenter))
    }
}

Screenshot_20220830-200127_ComposeDesign.pngGroup 1.png

1) 베너 영역

@OptIn(ExperimentalPagerApi::class)
@Composable
fun GameHeader() {
    val state = rememberPagerState()
    val bannerList = listOf(
        R.drawable.img_game_banner1,
        R.drawable.img_game_banner2,
        R.drawable.img_game_banner3
    )
    val titleList = listOf("SUPER MARIO", "KIRBY", "DIGIMON SURVIVE")

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(370.dp)
    ) {
        /** 베너 이미지 HorizontalPager **/
        HorizontalPager(
            count = 3,
            state = state
        ) { index ->
            BannerItem(bannerList[index], titleList[index])
        }

        /** Indicator **/
        HorizontalPagerIndicator(
            pagerState = state,
            activeColor = Color.White,
            inactiveColor = getGameGray(),
            modifier = Modifier
                .padding(bottom = 60.dp, end = 17.dp)
                .align(Alignment.BottomEnd)
        )

        LaunchedEffect(state.currentPage) {
            delay(3000)
            val newPosition = (state.currentPage + 1) % state.pageCount
            state.animateScrollToPage(newPosition)
        }

    }
}

@Composable
fun BannerItem(imageRes: Int, title: String) {
    Box(modifier = Modifier.fillMaxWidth()) {
        Image(
            painter = painterResource(id = imageRes),
            contentDescription = "banner",
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .fillMaxWidth()
                .height(370.dp)
        )
        /** 베너 하단 어둡게 처리 **/
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp)
                .background(
                    Brush.verticalGradient(
                        listOf(Color.Transparent, getGameBlack())
                    )
                )
                .align(Alignment.BottomCenter)
        )
        /** 타이틀 **/
        Text(
            text = title,
            color = Color.White,
            fontWeight = FontWeight.Bold,
            fontSize = 30.sp,
            modifier = Modifier
                .padding(start = 10.dp, bottom = 20.dp)
                .align(Alignment.BottomStart)
        )
        /** 게임 정보 **/
        Row(
            horizontalArrangement = Arrangement.spacedBy(18.dp),
            modifier = Modifier
                .fillMaxWidth()
                .align(Alignment.BottomStart)
                .padding(start = 10.dp, bottom = 2.dp)
        ) {
            Text(text = "10+", fontSize = 14.sp, color = getGameGray())
            Text(text = "2022", fontSize = 14.sp, color = getGameGray())
            Text(text = "Children,Puzzle", fontSize = 14.sp, color = getGameGray())
        }
    }
}

베너 영역은 Pager 사용이 메인인데 이건 바로 이전 글에 작성하였으므로 생략하겠습니다.

2) 메인 컨텐츠

Group 2.png

메인 컨텐츠는 다시 텝 영역과 컨텐츠 영역으로 나뉩니다.

우선 컨텐츠부터 보면 두 페이지로 나뉘게 됩니다.

Mask group.png

원래 디자인에서는 좀 더 내용이 있지만 중요한 부분도 아니고 해서…생략을 했습니다.

2-1) 컨텐츠

Crossfade(
    targetState = state.value,
    animationSpec = tween(durationMillis = 500, easing = LinearEasing)
) {
    if (it == "RECOMMEND") {
        GameRecommend()
    } else {
        GameRanking()
    }
}

Screen_Recording_20220830-204834_ComposeDesign_1.gif

두 화면은 Crossfade를 이용해서 약간 잔상이 보이며 화면이 전환되는 효과를 만들어 봤습니다.

추가로 티는 잘 안 나겠지만 두 번째 화면에 그러데이션 효과를 주었습니다.

원래 디자인에서도 바깥으로 가면서 약간 밝아지는 효과가 있어서 비슷하게 만들어봤습니다.

Box(
    modifier = Modifier
        .padding(top = 20.dp + iconSize / 2)
        .height(cardHeight)
        .fillMaxWidth()
        .clip(RoundedCornerShape(7.dp))
        .background(
            brush = Brush.radialGradient(
                colors = listOf(cardColor, Color.White),
                radius = 1100f
            )
        )
)

그러데이션의 종류는 여러 가지가 있는데 이건 다른 포스팅에서 다루도록 하겠습니다.

저는 이번에는 방사형 그러데이션이 필요로 하였습니다.

Modifier.background의 속성 중 brushBrush.radialGradient를 설정합니다.

colors에 원하는 색상들의 값을 넣어주고 radius의 값을 설정하여 원의 크기를 지정합니다.

이번 UI에서는 눈에 띄게 그러데이션이 보이는 게 아니어서 radius 값을 크게 줘서 그러데이션이 있는 듯 없는 듯 주었습니다.

2-2) 탭

Screen_Recording_20220830-212945_ComposeDesign_1.gif

탭에서는 두 가지 애니메이션을 적용시켰습니다.

첫 번째는 아이템을 선택할 때 Border 영역이 선택한 아이템 영역으로 움직이는 애니메이션입니다.

두 번째는 텍스트 색상이 채워지는 애니메이션입니다.

Box(
      modifier = Modifier
          .padding(horizontal = 14.dp)
          .fillMaxWidth()
          .height(36.dp)
          .border(1.dp, getGameGray(), RoundedCornerShape(6.dp))
  )
  /** Recommend, Ranking Tab **/
  Row(
      modifier = Modifier
          .padding(horizontal = 14.dp)
          .height(36.dp)
          .fillMaxWidth()
  ) {
      RecommendButton(
          isSelected = state.value == "RECOMMEND",
          modifier = Modifier.weight(1f)
      ) {
          state.value = "RECOMMEND"
      }
      RankingButton(
          isSelected = state.value == "RANKING",
          modifier = Modifier.weight(1f)
      ) {
          state.value = "RANKING"
      }
  }

  val alignmentState by animateAlignmentAsState(if (state.value == "RANKING") Alignment.TopEnd else Alignment.TopStart)
  Box(
      modifier = Modifier
          .padding(horizontal = 14.dp)
          .height(36.dp)
          .fillMaxWidth(0.5f)
          .align(alignmentState)
          .border(
              border = BorderStroke(1.dp, Color.White),
              shape = RoundedCornerShape(6.dp)
          )
  ) ...
}

우선 최상위 Box에서 전체 테두리를 지정해 주었습니다.

Row를 이용하여 탭을 구현하고 하단에 Box를 이용해서 선택된 ItemBoader를 구현하였습니다.

animateAlignmentAsState를 이용해서 아이템이 선택되었을 때 해당 아이템이 있는 Alignment를 지정하여 이동하게 하였습니다.

@OptIn(ExperimentalTextApi::class)
@Composable
fun RecommendButton(
    isSelected: Boolean,
    modifier: Modifier = Modifier,
    onClickListener: () -> Unit
) {
    val maxValue = 430f
    val startAnimation = animateFloatAsState(
        targetValue = if (isSelected) 0f else maxValue,
        animationSpec = tween(durationMillis = 500)
    )

    Box(
        modifier = modifier
            .height(36.dp)
            .clickable {
                onClickListener()
            }
            .background(Color.Transparent)
    ) {
        Text(
            text = "RECOMMEND",
            fontSize = 20.sp,
            fontWeight = FontWeight.Bold,
            color = getGameGray(),
            modifier = Modifier.align(Alignment.Center)
        )

        Text(
            text = "RECOMMEND",
            fontSize = 20.sp,
            fontWeight = FontWeight.Bold,
            color = getGameGray(),
            style = TextStyle(
                brush = Brush.horizontalGradient(
                    colors = listOf(getGameBlue(), getGamePurple()),
                    startX = startAnimation.value,
                    endX = maxValue,
                    tileMode = TileMode.Decal
                )
            ),
            modifier = Modifier.align(Alignment.Center)
        )
    }
}

우선 Text에 그러데이션이 있는데 이걸 구현하는 방법은 여러가지가 있겠지만 조사한 것들 중 간단한건 2가지였습니다.

modifier = Modifier.align(Alignment.Center)
        .graphicsLayer(alpha = 0.99f)
        .drawWithCache {
            val brush = Brush.horizontalGradient(listOf(getGameBlue(), getGamePurple()))
            onDrawWithContent {
                drawContent()
                drawRect(brush, blendMode = BlendMode.SrcAtop)
            }
        }

drawWithCahe를 이용하여 텍스트 위에 그러데이션을 그리고 graphicsLayer를 설정하여 텍스트 위에만 보이게 설정합니다.

사실 이 부분은 아직 학습 전이여서 정확하게 이해를 못 했습니다.

graphicsLayer 관련해서는 학습하고 포스팅하도록 하겠습니다. 그래서 이번에는 이걸 사용하지 않았습니다!

TextStyle(
        brush = Brush.horizontalGradient(
            colors = listOf(getGameBlue(), getGamePurple()),
            startX = startAnimation.value,
            endX = maxValue,
            tileMode = TileMode.Decal
)

두 번째 방식은 TextStyle을 이용하는 방법입니다.

하지만 TextStylebrush를 넣는 방법은 아직 테스트 단계이므로 언제 바뀔지는 알 수 없습니다.

horizontalGradient 효과를 줄 때 시작점과 종료점을 지정할 수 있는데 이를 이용하여 색상 채우는 애니메이션을 적용하였습니다.

아쉽게도 정확하게 Text의 크기를 알아내는 방식을 찾지 못해서 하드코딩으로 진행하게 되었습니다.

val maxValue = 430f
val startAnimation = animateFloatAsState(
    targetValue = if (isSelected) 0f else maxValue,
    animationSpec = tween(durationMillis = 500)
)

endXText의 가장 오른쪽에 배치해 두고 startX의 시작점이 선택 여부에 따라 선택되면 0 아니면 가장 오른쪽에 위치합니다.

그러므로 선택이 되었을 때 오른쪽에서 왼쪽으로 채워지게 되고 다른 아이템을 선택했을 때에는 왼쪽에서 오른쪽으로 색상이 지워지게 됩니다.

다른 버튼에서는 방향이 반대로 설정하면 됩니다.

@OptIn(ExperimentalTextApi::class)
@Composable
fun RankingButton(
    isSelected: Boolean,
    modifier: Modifier = Modifier,
    onClickListener: () -> Unit
) {
    val maxValue = 300f
    val startAnimation = animateFloatAsState(
        targetValue = if (isSelected) maxValue else 0f,
        animationSpec = tween(durationMillis = 500)
    )

    Box(
        modifier = modifier
            .height(36.dp)
            .clickable {
                onClickListener()
            }
            .background(Color.Transparent)
    ) {
        Text(
            text = "RANKING",
            fontSize = 20.sp,
            fontWeight = FontWeight.Bold,
            color = getGameGray(),
            modifier = Modifier.align(Alignment.Center)
        )

        Text(
            text = "RANKING",
            fontSize = 20.sp,
            fontWeight = FontWeight.Bold,
            color = getGameGray(),
            style = TextStyle(
                brush = Brush.horizontalGradient(
                    colors = listOf(getGameBlue(), getGamePurple()),
                    startX = 0f,
                    endX = startAnimation.value,
                    tileMode = TileMode.Decal
                )
            ),
            modifier = Modifier.align(Alignment.Center)
        )
    }
}

3) 하단 바

@Composable
fun GameFooter(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .background(getGameLightBlack())
    ) {
        Box(
            modifier = Modifier
                .size(82.dp, 57.dp)
                .background(getGameDarkGray())
                .align(Alignment.CenterEnd)
        ) {
            Icon(
                painter = painterResource(id = R.drawable.ic_search),
                contentDescription = "search",
                modifier = Modifier
                    .padding(start = 25.dp)
                    .size(34.dp)
                    .align(Alignment.Center)
            )
        }

        Box(
            modifier = Modifier
                .padding(start = 15.dp)
                .clip(CircleShape)
                .background(getGameBlue())
                .size(30.dp)
                .align(Alignment.CenterStart)
        )

        Row(
            horizontalArrangement = Arrangement.spacedBy(70.dp),
            modifier = Modifier.align(Alignment.Center)
        ) {
            ConstraintLayout {
                val (text, box) = createRefs()
                Text(
                    text = "HOME",
                    fontSize = 20.sp,
                    fontWeight = FontWeight.Bold,
                    color = Color.White,
                    modifier = Modifier.constrainAs(text) {
                        start.linkTo(parent.start)
                        top.linkTo(parent.top)
                    }
                )
                Box(
                    modifier = Modifier
                        .constrainAs(box) {
                            start.linkTo(text.start)
                            end.linkTo(text.end)
                            top.linkTo(text.bottom, 4.dp)
                            width = Dimension.fillToConstraints
                            height = Dimension.value(3.dp)
                        }
                        .background(Color.White, RoundedCornerShape(3.dp))
                )
            }
            Text(
                text = "FIND",
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold,
                color = getGameGray()
            )
        }

        Image(
            painter = painterResource(id = R.drawable.img_game_footer),
            contentDescription = "footer",
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .align(Alignment.CenterEnd)
                .padding(end = 37.dp)
                .height(57.dp)
        )

    }
}

하단 바는 곡선 부분을 구현해 볼까 싶었는데 Canvas 쓰는 것 외에는 생각이 나질 않아서 그냥 이미지로 대체해서 구현하였습니다.

728x90

'안드로이드 > 디자인' 카테고리의 다른 글

Compose 디자인  (0) 2022.08.24
댓글