안드로이드에서 반복되는 뷰를 구현하기 위해서는 RecyclerView를 사용합니다.
이를 사용하기 위해서는 RecyclerViewAdapter, ViewHolder 등 관련된 부수적인 것들을 구현해야 했습니다.
그런데 RecyclerView가 아주 많이 활용하는 뷰이다 보니 관련 코드 양도 그만큼 많아지는 문제가 있었습니다.
이제는 RecyclerView 대신 Compose를 사용하면 반복되는 뷰를 간단하게 구현할 수 있다고 합니다.
얼마나 쉽게 이런 뷰를 구현할 수 있는지 Compose 공부도 할 겸, 마인드가든의 복잡한 코드를 개선해 보겠습니다.
글을 읽기 전
- 공부를 하며 작성 중이라 글 중간에 나오는 코드는 최종 결과물이 아닐 가능성이 높습니다. 최종 코드는 깃헙을 참고해 주세요
- 마인드가든에서 개발하던 환경과 비슷하게 만들기 위해 간단한 프로젝트이지만 hilt, mvvm, flow 등을 적용했습니다.
저의 목표는 컴포즈 공부이기때문에 위에 언급한 개념을 모두 설명하면 글이 너무 길어질 것 같아 생략합니다.
Project GitHub
https://github.com/godjoy/InventoryCompose
기존 구현
다른 앱들과 마찬가지로 마인드가든에서도 RecyclerView를 여러 개 쓰고 있는 화면이 몇 개 있습니다.
그중 개선해보고 싶은 화면은 아래 보이는 나무 심기 화면입니다.
이 화면에서는 사용자들이 일기를 써서 모은 나무로 정원을 꾸밀 수 있습니다.
(한번 나무를 심어보고 싶다면, 트라이 해보셔도)
위쪽 RecyclerView 먼저 보겠습니다.
이 뷰에서는 이번 달 정원의 상태를 표시하고 사용자가 나무 심을 위치를 선택할 수 있습니다.
그래서 한 칸은 네 가지 상태를 가질 수 있습니다.
1. Planted : 잡초나 이미 심은 나무가 있는 상태
2. Lake : 물
3. Selected : 선택된 상태(선택한 나무가 표시된다)
4. Empty : 선택이 가능하지만 선택하지 않은 상태
1, 2의 경우 이미 뭔가 심어진 공간이기 때문에 클릭할 수 없어야 하고, 3의 경우는 활성화 표시가 되어야 합니다.
이 화면을 구현하는 방법은 어떤 게 있을까요?
반복되는 뷰의 조합이니 grid 형태의 recyclerview로 구현하는 게 효율적일 거라고 생각했고,
거기에다 상태에 따른 변형을 주기 위해 ItemViewType으로 구분하는 방법이 떠올랐습니다.
그래서 RecyclerView + 뷰홀더를 세 개 만들어 itemViewType으로 구분하는 방식으로 구현을 한 상태입니다.
(다른 방법은 어떤 게 있는지 궁금한데 알려주신다면~ 선물을 드리겠습니다 👍 )
아래쪽 Recyclerview는 나무 리스트를 보여주고 선택한 나무 아이템을 활성화합니다.
+ 위, 아래 RecyclerView 둘 다 아이템 하나만 클릭할 수 있도록 single selection을 구현했습니다.
이제 구현 사항에 따라 컴포즈로 구현해보겠습니다.
구현은 아래 순서로 진행하겠습니다.
1. 나무 아이템 목록
2. 정원
구현
우선 간단한 나무 목록부터 만들겠습니다.
이미지와 배경으로 이루어진 구조이며 클릭 시 테두리가 활성화되어야 합니다.
1. TreeRecyclerview, Compose 로 만들기
1-1. 나무 카드 만들기
1-2. 나무 카드 목록 만들기
1-3. 스크린에 배치하기
1-4. 상태 호이스팅
1-1. 나무 카드 만들기
recyclerView에서도 목록 구성을 위해 item을 만들었듯이 작은 나무 카드 하나부터 시작해 보겠습니다.
1. Card 사용해서 둥근 모서리, 테두리 설정하기
Card는 CardView와 같은 것으로 내부에 UI 요소들을 담는 컨테이너로 사용할 수 있습니다.
둥근 모서리, 테두리(border) 등에 관한 값을 인자로 전달하여 쉽게 UI를 만들 수 있습니다.
2. Image 사용해서 이미지 넣기
Painter는 기존 Drawable을 대체하는 API로 화면에 뭔가 그릴 때 사용합니다.
painterResource를 통해 이미지 소스를 전달하면 vector, drawable resource를 화면에 그릴 수 있습니다.
contentScale 을 사용하면 이미지 스케일 타입을 설정할 수 있습니다.
Card(
modifier = modifier
.width(53.dp)
.height(58.dp)
.border(2.dp, color, shape = RoundedCornerShape(5.dp))
.clickable {
clickState = !clickState
},
shape = RoundedCornerShape(5.dp),
backgroundColor = Color.LightGray,
) {
Image(
painter = painterResource(R.drawable.baseline_local_florist_24),
contentDescription = null,
contentScale = ContentScale.Inside
)
}
3. 클릭 시 테두리 활성화 기능 만들기
컴포즈에서 화면을 갱신하기 위한 공식은 f(상태) = UI입니다.
상태가 바뀔 때 컴포즈는 재구성(Recomposition)을 진행하여 UI를 갱신하기 때문입니다.
이러한 컴포즈가 재구성을 진행하는 대상은 State, MutableState 타입입니다.
(안드로이드에서 Livedata, RxJava, Flow로도 State 타입을 만들 수 있도록 지원하고 있습니다.)
재구성할 때 컴포저블 함수는 재시작되는데 이때 State, MutableState와 같은 상태값은 초기화됩니다.
이런 값을 유지하기 위해선 remember를 사용하여 컴포지션(컴포저블 트리 구조)에 저장해야 합니다.
이제 컴포지션에 저장되는 관찰 가능한 상태 객체를 만들어 보겠습니다.
선언 방법은 세 가지가 있는데, 많이 쓰일 것 같은 두 가지를 보자면 기본, by 키워드 사용이 있습니다.
첫 번째로 가장 기본적인 유형입니다.
val clickState = remember { mutableStateOf(false) }
이렇게 선언할 경우 객체 값에 접근할 때 .value 형식으로 사용해 주어야 합니다.
그러나 아래와 같이 by 키워드를 통해 get/set 에 대한 위임이 이루어지면서 객체를 그대로 사용할 수 있습니다.
대신 set을 통해 객체를 직접 변경할 수 있기 때문에 var 를 사용해주어야 합니다.
var clickState by remember { mutableStateOf(false) }
프리뷰 기능을 이용하여 결과 확인
전체 코드
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TreeCard(
modifier: Modifier = Modifier
) {
var clickState by remember { mutableStateOf(false) }
val color = if (clickState) Color.Green else Color.Transparent
Card(
shape = RoundedCornerShape(5.dp),
backgroundColor = Color.LightGray,
elevation = 0.dp,
modifier = modifier
.width(53.dp)
.height(58.dp)
.border(2.dp, color, shape = RoundedCornerShape(5.dp)),
onClick = { clickState = !clickState}
) {
Image(
painter = painterResource(R.drawable.baseline_local_florist_24),
contentDescription = null,
contentScale = ContentScale.Inside
)
}
}
Statefull composable
우선은 내부에서 상태를 저장하도록 구현을 해보았는데 이러한 컴포저블의 상태를 Statefull 하다고 합니다.
Statefull 한 컴포저블은 간단하게 구현할 수 있어 좋지만, 테스트나 재사용 측면에서는 좋은 코드가 아닙니다.
이를 해결하기 위해 아래(1-4)에서 State Hoisting(상태 끌어올리기)로 Stateless한 상태를 만들어 보겠습니다.
1-2. 나무 카드 목록 만들기
목록을 만들기 전 모델을 만들겠습니다.
표시할 아이템 모델
data class TreeState(
val id: Int,
var isSelected: Boolean
)
LazyRow로 목록 만들기
컴포즈에서 리스트나 그리드를 그리기 위해서 LazyList(LazyColumn, LazyRow, LazyGrid)를 사용합니다.
LazyColumn은 수직 스크롤, LazyRow 수평 스크롤을 위해 사용할 수 있습니다.
저는 가로 방향 스크롤이 필요하기 때문에 LazyRow를 사용하여 구현하겠습니다.
@Composable
fun TreeCards(
trees: List<TreeState>,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(top = 20.dp, bottom = 20.dp, start = 15.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = trees, key = { tree -> tree.id }
) { tree ->
TreeCard()
}
}
}
LazyRow에서 아이템 사이 간격을 위해 HorizontalArrangement를 사용할 수 있습니다.
(LazyColumn에서는 VerticalArrangement를 통해 간격을 조절할 수 있습니다.)
Recyclerview에서 ItemDecorator를 사용할 때보다 훨씬 간결하게 작성할 수 있었습니다.
보여주고자 하는 리스트 전달을 위해선 LazyScopeDsl.items()를 사용합니다.
이때 중요한 것은 key 값을 리스트와 함께 전달해야 합니다. 키를 전달하지 않을 경우 데이터 위치에 따라 키 값이 지정되는데, 데이터 목록 구성에 변경이 생길 경우 문제가 발생할 수도 있기 때문입니다.
프리뷰 기능을 이용하여 결과 확인
1-3. 아래쪽에 배치하기
@Composable
fun InventoryScreen(
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row (
modifier = modifier.fillMaxHeight(),
verticalAlignment = Alignment.Bottom
) {
TreeCards(trees = mockTreeList)
}
}
}
전체적으로 뷰를 세로로 배치할 것이기 때문에 Column 사용했고, 나무 목록은 아래에 위치해야 해서 Row를 사용하여 verticalAlignment를 Bottom으로 잡아주었습니다.
1-4. 상태 호이스팅
컴포저블 내부에 저장한 상태를 컴포저블 호출부(caller)로 옮기는 것을 상태 호이스팅이라고 합니다.
(상태 호이스팅은 선택적인 것으로 간단한 로직에서는 그냥 사용해도 된다고 합니다)
컴포즈에서는 상위 -> 하위 방향로 state가 전달되며, 하위 -> 상위 방향으로 event가 전달되는 단방향 데이트 흐름(UDF)를 지향합니다. 이 방식으로 구성할 경우, 가독성이 좋아지며 재사용과 테스트하기에 더 쉬운 구조가 된다는 이점이 있습니다.
안드로이드에서는 이러한 패턴에서 끌어올린 상태들을 관리하기 위해 상태 홀더를 쓸 것을 권장하고 있습니다.
상태 홀더에는 다음 두 가지 유형이 있습니다.
1. UI 상태 홀더(일반 클래스 상태 홀더)
2. ViewModel
소비되는 위치와 가장 가까운 상태 홀더를 사용하여 UI 상태를 생성해야 하는데,
상태가 비즈니스 로직에서 필요하다면 ViewModel을 사용하고, UI에서만 필요하다면 UI 상태홀더를 사용합니다.
UI 상태 홀더 VS ViewModel 어디에 저장해야 할까?
나무 아이템을 클릭할 경우의 상태는 일반 클래스 상태 홀더와 ViewModel 중 어디에 저장해야 할지 고민을 해봤습니다.
나무 아이템을 클릭한다고 바로 내부 DB가 수정되는 것은 아닙니다. 심을 위치를 선택하고 완료 버튼까지 눌러야 비로소 비즈니스 로직이 필요하게 됩니다. 그래서 처음엔 화면을 갱신하는 데 사용이 되는 상태이니 UI 상태 홀더에 저장해야 된다고 생각했는데,
더 생각해 보니 완료 버튼을 누를 경우 결국에는 비즈니스 로직에서 상태를 소비하기 때문에 최종적으로 ViewModel에 저장해야 한다는 결론을 내렸습니다.
1-4-1. ViewModel로 상태 호이스팅
뷰모델을 작성하고 나머지 컴포저블 함수에서 내부 상태를 없애보겠습니다.
UDF에 따라, UI는 상태를 가진 곳으로 이벤트를 전달하고 이벤트를 받은 뷰모델은 처리하여 상태로 만듭니다.
Inventory 뷰모델에서는 나무 목록 상태와 나무 선택 이벤트를 처리하여 새로운 상태를 만들어줄 함수가 필요합니다.
ViewModel 코드
@HiltViewModel
class InventoryViewModel @Inject constructor(
private val getTreesUseCase: GetTrees
) : ViewModel() {
private val selectedTree = MutableStateFlow(1)
private var trees: List<TreeItem>
// StateFlow<Int> 를 StateFlow<List<TreeItem>으로 변환하는 로직
// selectedTree가 변경될 때마다 treeState 값을 갱신할 수 있다
@OptIn(ExperimentalCoroutinesApi::class)
val treeState: StateFlow<List<TreeItem>> = selectedTree.mapLatest { selectedId ->
trees.map { tree ->
if (tree.id == selectedId) tree.copy(isSelected = true)
else tree.copy(isSelected = false)
}.also { trees = it }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), trees)
// 나무 목록에서 아이템 클릭 이벤트 발생 시 selectedTree 업데이트
fun selectTree(newSelectedId: Int) {
selectedTree.value = newSelectedId
}
}
컴포즈에서 뷰모델을 사용하는 예시를 찾아보았을 때 상태 저장을 위해 State를 사용하는 경우도 있었습니다만,
1. Flow를 Compose에서 사용할 수 있도록 State로 변환하는 함수를 제공하고 있다
2. 뷰모델이니 최대한 UI 관련(컴포즈 포함) 의존성을 피하는게 좋지 않을까
이렇게 두가지 이유로 StateFlow를 사용하였습니다.
InventoryScreen 코드
@Composable
fun InventoryScreen(
modifier: Modifier = Modifier,
viewModel: InventoryViewModel = hiltViewModel()
) {
val treeState: List<TreeItem> by viewModel.treeState.collectAsState()
Row(
modifier = modifier.fillMaxHeight(),
verticalAlignment = Alignment.Bottom
){
TreeCards(
trees = treeState,
onSelect = { id -> viewModel.selectTree(id) }
)
}
}
StateFlow를 State로 변환하고자 collectAsSate()를 사용하였습니다.
ShareFlow는 produceState라는 함수를 통해 State<T>로 반환됨을 알 수 있습니다.
TreeCards 코드
@Composable
fun TreeCards(
trees: List<TreeState>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(top = 20.dp, bottom = 20.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = trees, key = { tree -> tree.id }
) { treeState ->
TreeCard(
treeState,
{ onSelect(treeState.id) }
)
}
}
}
Stateless 해진 TreeCard 코드
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TreeCard(
treeState: TreeState,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
var color = Color.Transparent
if (treeState.isSelected) color = Color.Green
Card(
shape = RoundedCornerShape(5.dp),
backgroundColor = Color.LightGray,
elevation = 0.dp,
modifier = modifier
.width(53.dp)
.height(58.dp)
.border(2.dp, color, shape = RoundedCornerShape(5.dp)),
onClick = { onSelect(treeState.id) }
) {
Image(
painter = painterResource(treeState.id.mapToDrawableId()),
contentDescription = null,
contentScale = ContentScale.Inside
)
}
}
결과
2. GardenRecyclerView, Compose 로 만들기
이제 조금 더 복잡한 뷰를 만들겠습니다.
이번에는 뷰모델을 만들어둔 상태이기 때문에 뷰모델 관련 코드부터 작성하도록 하겠습니다.
2-1. ViewModel 코드 작성
2-2. 타입 별 정원 카드 만들기
2-3. 정원 카드 목록 만들기
2-4. 스크린에 배치하기
2-1. ViewModel 코드 작성
ViewModel에서 사용할 GardenItem, GardenState 모델을 만들었습니다.
// 정원 카드 데이터 모델
data class GardenItem(
val treeId: Int? = null,
val location: Int,
val type: GardenType
)
enum class GardenType {
Lake, // 호수
Planted, // 심어진 상태
Selected, // 선택된 상태
Empty // 선택 가능, 비어있는 상태
}
// 정원의 상태
data class GardenState(
val isLoading: Boolean,
val selectedTreeId: Int = 1,
val selectedLocation: Int = 6,
val garden: List<GardenItem> = defaultGarden
)
ViewModel 코드
@HiltViewModel
class InventoryViewModel @Inject constructor(
private val getGardenUseCase: GetGarden
) : ViewModel() {
private val _gardenState = MutableStateFlow<GardenState>(GardenState(isLoading = true))
val gardenState = _gardenState.asStateFlow()
init {
// defaultLocation = 6
selectLocation(6)
}
// 나무 목록에서 나무 선택 이벤트 발생 시 정원에도 선택된 나무를 표시할 수 있도록 갱신한다
fun selectTree(newSelectedId: Int) {
_gardenState.update { curState ->
curState.copy(
selectedTreeId = newSelectedId,
garden = curState.garden.map { gardenItem ->
if (gardenItem.type == GardenType.Selected) gardenItem.copy(treeId = newSelectedId)
else gardenItem
}
)
}
}
// 정원에 선택한 위치를 표시할 수 있도록 상태를 갱신한다
fun selectLocation(newLocation: Int) {
_gardenState.update { curState ->
curState.copy(
selectedLocation = newLocation,
garden = curState.garden.map { gardenItem ->
if (gardenItem.location == newLocation)
gardenItem.copy(
type = GardenType.Selected,
treeId = curState.selectedTreeId
)
else {
if (gardenItem.type == GardenType.Selected)
gardenItem.copy(type = GardenType.Empty, treeId = null)
else gardenItem
}
}
)
}
}
}
2-2. 타입 별 정원 카드 만들기
정원이 가질 수 있는 타입은 네가지였습니다.
1. Lake
@Composable
fun GardenLakeCard(modifier: Modifier = Modifier) {
Card(
shape = RoundedCornerShape(4.dp),
modifier = modifier
.aspectRatio(1f / 1f),
backgroundColor = Color.Blue
) {}
}
2. Planted : 이미 심어져 있는 상태이기 때문에 어떤 나무가 심어져 있는지 표시합니다. (이미지 대신 텍스트로 대체)
@Composable
fun GardenPlantedCard(
modifier: Modifier = Modifier,
gardenItem: GardenItem
) {
Card(
shape = RoundedCornerShape(4.dp),
modifier = modifier
.aspectRatio(1f / 1f)
.border(width = 2.dp, color = Color.Green, RoundedCornerShape(4.dp))
) {
Text(
text = "${gardenItem.treeId}",
modifier = modifier.wrapContentHeight(),
textAlign = TextAlign.Center
)
}
}
aspectRatio를 사용하면 비율로 크기를 설정할 수 있습니다.
3. Selected, 4. Empty : 클릭 이벤트가 발생하며, Selected일 경우 현재 선택한 나무를 표시하고 Empty일 경우 비워둡니다.
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun GardenCard(
modifier: Modifier = Modifier,
gardenItem: GardenItem,
onSelect: (Int) -> Unit
) {
val backgroundColor =
if (gardenItem.type == GardenType.Selected) Color.Yellow else Color.White
Card(
shape = RoundedCornerShape(4.dp),
modifier = modifier
.aspectRatio(1f / 1f)
.border(width = 2.dp, color = Color.Green, RoundedCornerShape(4.dp)),
backgroundColor = backgroundColor,
onClick = { onSelect(gardenItem.location) }
) {
gardenItem.treeId?.let {
Text(
text = "$it",
modifier = modifier.wrapContentHeight(),
textAlign = TextAlign.Center
)
}
}
}
2-3. 정원 카드 목록 만들기
@Composable
fun GardenCards(
modifier: Modifier = Modifier,
garden: List<GardenItem>,
onSelect: (Int) -> Unit
) {
LazyVerticalGrid(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
// grid column의 수 지정
columns = GridCells.Fixed(6),
// 가로 세로 간격 조절
verticalArrangement = Arrangement.spacedBy(5.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
items(items = garden, key = { item: GardenItem -> item.location }) { gardenItem ->
when(gardenItem.type) {
GardenType.Lake -> GardenLakeCard()
GardenType.Planted -> GardenPlantedCard(gardenItem = gardenItem)
GardenType.Selected, GardenType.Empty ->
GardenCard(gardenItem = gardenItem) { onSelect(gardenItem.location) }
}
}
}
}
격자모양이기 때문에 LazyVeticalGrid를 사용하여 구현하였습니다.
LazyColumn과 마찬가지로 items로 표시할 데이터를 넘기는 방법은 동일합니다.
정원의 타입에 따라 표시해야할 컴포저블이 다르기 때문에 이를 when으로 처리해주었습니다.
간단하네요~
2-4. 스크린에 배치하기
@Composable
fun InventoryScreen(
modifier: Modifier = Modifier,
viewModel: InventoryViewModel = hiltViewModel()
) {
val treeState: List<TreeItem> by viewModel.treeState.collectAsStateWithLifecycle()
val gardenState: GardenState by viewModel.gardenState.collectAsStateWithLifecycle()
val statusText by viewModel.statusText.collectAsStateWithLifecycle()
Box(
modifier = modifier
.fillMaxSize()
.padding(start = 16.dp, end = 16.dp)
) {
Column(
modifier = modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Center
) {
Text(
text = statusText,
modifier = modifier.padding(bottom = 15.dp),
fontSize = 20.sp
)
GardenCards(
garden = gardenState.garden,
onSelect = { location -> viewModel.selectLocation(location) }
)
}
Row(
modifier = modifier.fillMaxHeight(),
verticalAlignment = Alignment.Bottom
) {
TreeCards(
trees = treeState,
onSelect = { id -> viewModel.selectTree(id) }
)
}
}
}
collectAsStateWithLifecycle() 를 사용하여 Flow 생명주기 처리하기
Flow는 Livedata와 달리 앱이나 화면의 생명주기를 알 필요가 없기 때문에 적절한 처리를 해주지 않으면 UI가 보이지 않는 상황에서도 계속 데이터를 수집하여 자원의 낭비가 일어날 수도 있습니다.
그렇기 때문에 Activity나 Fragment 등의 화면의 생명주기와 데이터 수집 시점을 맞추고 싶다면 Flow를 lifecycleScope.launchWhenStarted, repeatOnLifeCycle 등과 함께 사용해야 합니다.
컴포즈에서 역시 Flow를 사용할 때 위와 같은 처리를 해주지 않는다면, 화면이 보이지 않는 상황에서도 Flow의 수집이 계속되어 State 변경이 일어나게 되고 이로 인해 리컴포지션이 발생하게 됩니다.
이러한 낭비를 해결하기 위해 collectAsStateWithLifecycle()을 사용할 수 있습니다.
이 함수는 내부적으로 repeatOnLifeCycle의 방식을 사용하고 있음을 확인할 수 있습니다.
최종 결과
Meaning
- LazyList 활용
- Compose에서 UI 배치하기 (Row, Column, Box, Card)
- remember, State를 사용하여 컴포지션에 상태 저장하고 UI 업데이트 하기
- 클릭 이벤트 처리하기
- ViewModel을 사용한 상태 관리
- Compose에서 Flow 생명주기 관리
선언형 UI 이다보니 사용하면서 리액트 같다는 느낌을 받았고, 신선하고 재밌었습니다ㅎㅎ
RecyclerView를 사용할 때 RecyclerViewAdapter, ViewHolder, xml, databinding 등에 관한 코드를 작성했어야 했는데
컴포즈에서는 이런 것들 없이도 구현이 가능하니 확실히 효율적임을 알 수 있었습니다.
xml을 사용하는 기존 방식과 비교했을 때 뷰를 그리는데 있어서 내부적으로 성능이 뛰어남을 구글에서 강조하고 있기도 하고,
따라오는 장점이 많기 때문에 이번을 계기로 기존 마인드가든 코드도 Compose로 열심히 마이그레이션 해야할 것 같습니다.
Next
- 반응형 UI로 만들어 여러 기기 사이즈에 대응하기
참고
'Android' 카테고리의 다른 글
Compose Tutorial : 기본 레이아웃 (1) | 2024.01.14 |
---|---|
🐘 Groovy 에서 KTS로 전환하기 (0) | 2023.03.03 |
Hilt (0) | 2022.03.25 |
[Android] Very Long Vector Path issue (0) | 2021.08.17 |
Vector Asset (0) | 2021.07.25 |