나만의 안드로이드 앱 만들기(초보자 편) - 페이지 구성 (7)
본 편부터 실질적인 앱 개발을 진행 해볼 예정입니다.
큰 맥락에서의 작업은 다음과 같습니다.
- 메인의 홈 탭을 구성합니다.
- 현재 진행 중인 더치페이의 총합을 표시합니다.
- 더치 페이 추가 가능한 상세 페이지로 이동 가능한 버튼을 추가합니다.
- 진행 중인 더치 페이 목록을 노출합니다.
- 더치 페이 추가 페이지를 구성합니다.
- 지출 내용을 입력합니다.
- 총 가격을 입력합니다.
- 참여 인원을 추가합니다.
- 메인의 홈 탭에 추가된 리스트를 노출합니다.
위와 같은 작업을 통해 페이지를 구성해볼 예정입니다.
한번에 개발을 진행하기 보다는 이번 7편에서는 UI 작업만 진행할 예정이고, 8편에서 데이터 연동을 진행해보려 합니다.
UI 작업에 있어서 가장 중요한 부분은 MVI 패턴에 대한 이해입니다.
MVI 패턴은 상태 관리가 매우 중요한 디자인 패턴으로 Compose 를 사용함에 있어 가장 중요합니다.
또한 우리가 만들고 있는 앱은 Single Activity(Navigation) 를 사용하고 있기 때문에 이 부분도 정확하게 인지하고 있어야 합니다.
신규 페이지를 추가할때 작업 순서는 다음과 같습니다.
- PageList 내에 페이지 정보를 작성합니다.
- PageMoveActions 내 navigate 를 작성합니다.
- NavGraph 내 page 를 작성합니다.
- page 내 screen 및 viewmodel 을 작성합니다.
- viewmodel 에 사용할 usecase 를 작성합니다.
- usecase 에서 데이터 조회를 위한 repository 및 datasource 를 작성합니다.
1~4번 까지의 작업을 진행해보겠습니다.
1. PageList 내에 페이지 정보를 작성합니다.
interface SnBasePage {
val description: String
val route: String
}
interface SnPage : SnBasePage
interface SnTabPage : SnBasePage
interface SnTabInPage {
val description: String
val className: String
}
sealed class PageList : SnPage {
...
object Main : SnTabPage {
override val description = "메인 페이지"
override val route: String = "Main"
object HomeTab: SnTabInPage {
override val description: String = "메인의 홈 탭"
override val className = "HomeTab"
}
object StorageTab: SnTabInPage {
override val description: String = "메인의 저장소 탭"
override val className = "StorageTab"
}
}
object HomeWrite : SnPage {
override val description = "홈 글쓰기 페이지"
override val route = "HomeWrite"
}
...
}
2. PageMoveActions 내 navigate 를 작성합니다.
class PageMoveActions(navController: NavController) :
Common,
Main,
Home
{
...
override val gotoHomeWrite: () -> Unit = {
navController.navigate(PageList.HomeWrite.route)
}
...
}
interface Home : BasePageMoveActions {
val gotoHomeWrite: () -> Unit
}
3. NavGraph 내 page 를 작성합니다.
@Composable
fun NavGraph() {
val navController = rememberNavController()
val actions = remember(navController) { PageMoveActions(navController) }
NavHost(
navController,
startDestination = PageList.startDestination,
) {
...
// 홈 글쓰기 페이지
buildTopToBottomPage(PageList.HomeWrite.route) {
HomeWritePage(actions, it)
}
}
}
4. page 내 screen 및 viewmodel 을 작성합니다.
* screen 내 디자인의 경우 최소한의 작업만 진행하였습니다.
Contract 를 작성합니다.
class HomeContract {
sealed class Event : BaseViewEvent {
object HomeWriteButtonClicked : Event()
}
data class State(
override val screenState: MutableState<SdV1ScreenStateEnum>,
val list: MutableList<HomeDutchListItem>
) : BaseViewState
sealed class Effect : BaseViewSideEffect {
sealed class Navigation : Effect() {
object GoToHomeWrite : Navigation()
}
}
}
ViewModel 을 작성합니다.
@HiltViewModel
class HomeViewModel @Inject constructor(
private val userInfoUseCase: UserInfoUseCase,
) : SdV1ViewModel<HomeContract.Event, HomeContract.State, HomeContract.Effect>() {
init {
super.onCreate(
initialData = {
setInitialData()
},
initialHandleUiMonitoring = {
handleUiMonitoring()
}
)
}
override fun setInitialState(): HomeContract.State = HomeContract.State(
screenState = mutableStateOf(SdV1ScreenStateEnum.SUCCESS),
list = mutableListOf()
)
override suspend fun setInitialData() {
// 데이터 추가
}
override fun handleUiMonitoring() {}
override fun handleEvents(event: HomeContract.Event) {
when (event) {
is HomeContract.Event.HomeWriteButtonClicked -> setEffect {
HomeContract.Effect.Navigation.GoToHomeWrite
}
}
}
}
Contract 및 ViewModel 는 아주 기본적으로 작성해둔 상태이며, 데이터 연동 시 추가적으로 더 작성해보도록 하겠습니다.
그 다음 메인 홈 탭의 디자인을 다음과 같이 수정합니다.
@Preview(showBackground = true)
@Composable
fun HomeScreenPreview() {
val list = arrayListOf<HomeDutchListItem>()
repeat(3) {
list.add(HomeDutchListItem(name = "${it}차", price = 1000 * it, enterCount = 1 + it))
}
HomeScreen(
state = HomeContract.State(
screenState = remember { mutableStateOf(SdV1ScreenStateEnum.SUCCESS) },
list = list
),
effectFlow = null,
onEventSent = {},
onNavigationRequested = {},
)
}
@Composable
fun HomeScreen(
state: HomeContract.State,
effectFlow: Flow<HomeContract.Effect>?,
onEventSent: (event: HomeContract.Event) -> Unit,
onNavigationRequested: (HomeContract.Effect.Navigation) -> Unit
) {
LaunchedEffect(SIDE_EFFECTS_KEY) {
effectFlow?.collect { effect ->
when (effect) {
is HomeContract.Effect.Navigation -> {
onNavigationRequested(effect)
}
}
}
}
BaseScreen(
screenState = state.screenState,
body = {
// 총 가격
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp),
contentAlignment = Alignment.Center
) {
Text(text = "0원", fontSize = 30.sp)
}
Button(
onClick = { onEventSent(HomeContract.Event.HomeWriteButtonClicked) },
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 40.dp),
colors = ButtonDefaults.buttonColors(containerColor = Blue)
) {
Box(
modifier = Modifier.height(30.dp),
contentAlignment = Alignment.Center
) {
Text(text = "추가하기", fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
HomeList(state.list)
}
)
}
홈 스크린 내 필요한 리스트도 추가합니다.
@Preview(showBackground = true)
@Composable
fun HomeListPreview() {
arrayListOf<HomeDutchListItem>().let { list ->
repeat(3) {
list.add(HomeDutchListItem(name = "${it}차", price = 1000 * it, enterCount = 1 + it))
}
HomeList(list)
}
}
@Preview(showBackground = true)
@Composable
fun HomeListEmptyPreview() {
HomeList(arrayListOf())
}
@Composable
fun HomeList(list: MutableList<HomeDutchListItem>) {
// 더치 페이 목록
if (list.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(
RoundedCornerShape(
topStart = 30.dp,
topEnd = 30.dp
)
)
.background(LightGray),
contentAlignment = Alignment.Center
) {
Text(text = "리스트를 추가해보세요.")
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(LightGray),
contentPadding = PaddingValues(20.dp)
) {
items(list) {
HomeListItem(it)
}
}
}
}
@Composable
fun HomeListItem(item: HomeDutchListItem) {
Column {
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
) {
val (name, price, enterCount, divider) = createRefs()
Text(
text = item.name,
modifier = Modifier.constrainAs(name) {
start.linkTo(parent.start)
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
}
)
Text(
text = "${item.price}원",
modifier = Modifier.constrainAs(price) {
end.linkTo(parent.end)
top.linkTo(parent.top)
bottom.linkTo(enterCount.top)
}
)
Text(
text = "${item.enterCount}명",
modifier = Modifier.constrainAs(enterCount) {
end.linkTo(parent.end)
top.linkTo(price.bottom)
bottom.linkTo(parent.bottom)
}
)
}
Divider(
modifier = Modifier.padding(top = 5.dp, bottom = 10.dp)
)
}
}
홈 페이지 내 네이게이션도 추가해줍니다.
@Composable
fun HomePage(actions: PageMoveActions, navBackStackEntry: NavBackStackEntry) {
val coroutineScope: CoroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val viewModel: HomeViewModel = viewModel(
factory = HiltViewModelFactory(context, navBackStackEntry)
)
HomeScreen(
state = viewModel.viewState.value,
effectFlow = viewModel.effect,
onEventSent = { event -> viewModel.setEvent(event) },
onNavigationRequested = { navigationEffect ->
coroutineScope.launch {
when (navigationEffect) {
is HomeContract.Effect.Navigation.GoToHomeWrite -> actions.gotoHomeWrite.invoke()
}
}
}
)
}
여기까지의 코드는 다음과 같은 형태를 띄고 있고 유효한 기능은 추가하기 버튼입니다.
추가하기 버튼을 클릭하면 홈 글쓰기 페이지로 이동 가능합니다.
이동하게되는 홈 글쓰기 페이지 에 대한 작업은 다음과 같습니다.
Contract 를 작성합니다.
class HomeWriteContract {
sealed class Event : BaseViewEvent {
object BackButtonClicked : Event()
object SaveEnterPersonClicked : Event()
data class RemoveEnterPersonClicked(val position: Int) : Event()
object CompleteButtonClicked : Event()
}
data class State(
override val screenState: MutableState<SdV1ScreenStateEnum>,
val title: MutableStateFlow<String>,
val amount: MutableStateFlow<String>,
val enterPersonName: MutableState<String>,
val enterPersonList: SnapshotStateList<String>,
val completeButtonEnabled: MutableState<Boolean>
) : BaseViewState
sealed class Effect : BaseViewSideEffect {
sealed class Toast : Effect() {
object ShowComplete : Toast()
}
sealed class Navigation : Effect() {
object GoToBack : Navigation()
}
}
}
ViewModel 을 작성합니다.
@HiltViewModel
class HomeWriteViewModel @Inject constructor(
private val userInfoUseCase: UserInfoUseCase,
) : SdV1ViewModel<HomeWriteContract.Event, HomeWriteContract.State, HomeWriteContract.Effect>() {
init {
super.onCreate(
initialData = {
setInitialData()
},
initialHandleUiMonitoring = {
handleUiMonitoring()
}
)
}
override fun setInitialState(): HomeWriteContract.State = HomeWriteContract.State(
screenState = mutableStateOf(SdV1ScreenStateEnum.SUCCESS),
title = MutableStateFlow(""),
amount = MutableStateFlow(""),
enterPersonName = mutableStateOf(""),
enterPersonList = mutableStateListOf(),
completeButtonEnabled = mutableStateOf(false)
)
override suspend fun setInitialData() {
// 데이터 추가
}
override fun handleUiMonitoring() {
viewState.value.title.onEach {
setState {
copy(
completeButtonEnabled = mutableStateOf(
it.isNotEmpty() && amount.value.isNotEmpty() && enterPersonList.isNotEmpty()
)
)
}
}.launchIn(viewModelScope)
viewState.value.amount.onEach {
setState {
copy(
completeButtonEnabled = mutableStateOf(
title.value.isNotEmpty() && it.isNotEmpty() && enterPersonList.isNotEmpty()
)
)
}
}.launchIn(viewModelScope)
}
override fun handleEvents(event: HomeWriteContract.Event) {
when (event) {
is HomeWriteContract.Event.BackButtonClicked -> setEffect {
HomeWriteContract.Effect.Navigation.GoToBack
}
is HomeWriteContract.Event.SaveEnterPersonClicked -> {
if (viewState.value.enterPersonName.value.isEmpty()) return
viewState.value.enterPersonList.add(viewState.value.enterPersonName.value)
setState {
copy(
enterPersonName = mutableStateOf(""),
completeButtonEnabled = mutableStateOf(
title.value.isNotEmpty() && amount.value.isNotEmpty() && enterPersonList.isNotEmpty()
)
)
}
}
is HomeWriteContract.Event.RemoveEnterPersonClicked -> {
viewState.value.enterPersonList.removeAt(event.position)
setState {
copy(
completeButtonEnabled = mutableStateOf(
title.value.isNotEmpty() && amount.value.isNotEmpty() && enterPersonList.isNotEmpty()
)
)
}
}
is HomeWriteContract.Event.CompleteButtonClicked -> {
if (!viewState.value.completeButtonEnabled.value) return
setEffect { HomeWriteContract.Effect.Toast.ShowComplete }
setEffect { HomeWriteContract.Effect.Navigation.GoToBack }
}
}
}
}
홈 글쓰기 페이지 내 디자인도 추가합니다.
@Preview(showBackground = true)
@Composable
fun HomeWriteScreenPreview() {
val list = arrayListOf<HomeDutchListItem>()
repeat(3) {
list.add(HomeDutchListItem(name = "${it}차", price = 1000 * it, enterCount = 1 + it))
}
HomeWriteScreen(
state = HomeWriteContract.State(
screenState = remember { mutableStateOf(SdV1ScreenStateEnum.SUCCESS) },
title = remember { MutableStateFlow("") },
amount = remember { MutableStateFlow("") },
enterPersonName = remember { mutableStateOf("") },
enterPersonList = remember { mutableStateListOf() },
completeButtonEnabled = remember { mutableStateOf(false) }
),
effectFlow = null,
onEventSent = {},
onNavigationRequested = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeWriteScreen(
state: HomeWriteContract.State,
effectFlow: Flow<HomeWriteContract.Effect>?,
onEventSent: (event: HomeWriteContract.Event) -> Unit,
onNavigationRequested: (HomeWriteContract.Effect.Navigation) -> Unit
) {
val context = LocalContext.current
LaunchedEffect(SIDE_EFFECTS_KEY) {
effectFlow?.collect { effect ->
when (effect) {
is HomeWriteContract.Effect.Toast -> homeWriteScreenToast(context, effect)
is HomeWriteContract.Effect.Navigation -> onNavigationRequested(effect)
}
}
}
BaseScreen(
screenState = state.screenState,
topBar = {
AppBar(
text = "더치 페이 추가하기",
startImageClickAction = { onEventSent(HomeWriteContract.Event.BackButtonClicked) }
)
},
body = {
TextField(
value = state.title.collectAsState().value,
onValueChange = { state.title.value = it },
label = {
Text(text = "지출 내용")
},
placeholder = {
Text(text = "1차 고기집")
},
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Text
),
)
TextField(
value = state.amount.collectAsState().value,
onValueChange = { state.amount.value = it },
label = {
Text(text = "총 가격 (원)")
},
placeholder = {
Text(text = "100,000")
},
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Number
),
visualTransformation = AmountTransformation
)
TextField(
value = state.enterPersonName.value,
onValueChange = { state.enterPersonName.value = it },
label = {
Text(text = "참여 인원 이름")
},
placeholder = {
Text(text = "사람1")
},
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Text
),
keyboardActions = KeyboardActions {
onEventSent(HomeWriteContract.Event.SaveEnterPersonClicked)
}
)
LazyColumn(
contentPadding = PaddingValues(20.dp)
) {
state.enterPersonList.size.also {
if (it > 0) {
item {
Text(
text = "총 ${state.enterPersonList.size}명",
modifier = Modifier.padding(start = 5.dp, bottom = 10.dp)
)
}
}
}
itemsIndexed(state.enterPersonList) { position, name ->
Button(
onClick = {
onEventSent(HomeWriteContract.Event.RemoveEnterPersonClicked(position))
},
colors = ButtonDefaults.buttonColors(containerColor = Gray)
) {
Row {
Text(
text = name,
modifier = Modifier.weight(1f)
)
Image(
painter = painterResource(id = R.drawable.clear),
contentDescription = null
)
}
}
}
}
},
footer = {
Button(
onClick = {
onEventSent(HomeWriteContract.Event.CompleteButtonClicked)
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 20.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (state.completeButtonEnabled.value) Blue else LightGray
)
) {
Box(
modifier = Modifier.height(30.dp),
contentAlignment = Alignment.Center
) {
Text(text = "추가하기", fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
}
)
}
fun homeWriteScreenToast(context: Context, effect: HomeWriteContract.Effect.Toast) {
when(effect) {
is HomeWriteContract.Effect.Toast.ShowComplete -> {
Toast.makeText(context, "추가 되었습니다.", Toast.LENGTH_SHORT).show()
}
}
}
위 디자인에서 2가지 파일이 필요합니다.
AmountTransformation
object AmountTransformation : VisualTransformation{
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
text = AnnotatedString(CharFormatUtils.amount(text.text)),
offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return CharFormatUtils.amount(text.text).length
}
override fun transformedToOriginal(offset: Int): Int {
return text.length
}
}
)
}
}
CharFormatUtils
object CharFormatUtils {
fun amount(amount: Any?): String {
// null 이면 빈값으로 반환함
if (amount == null) {
return ""
}
return try {
val amountToLong = amount.toString().toLong()
val formatter = NumberFormat.getNumberInstance(Locale("ko_KR"))
val result = formatter.format(amountToLong)
return if (
amountToLong > 1000 &&
!result.contains(",")
) {
NumberFormat.getNumberInstance(Locale.getDefault()).format(amountToLong)
} else {
result
}
} catch (e: NumberFormatException) {
amount.toString()
}
}
}
AmountTransformation 의 경우 TextField 내 입력 값의 포맷을 설정해주는 기능입니다.
CharFormatUtils 는 AmountTransformation 에서 적용할 포맷을 작성한 유틸입니다.
현재 TextField 내 가격이 입력될때 숫자 포맷을 적용하기 위한 목적으로 사용되고 있습니다.
이 기능은 실제 데이터를 변경하지 않고, 눈에 보이는 값만 변경하여 보여줍니다.
또한 아이콘이 필요한데 필요한 아이콘은 다음 링크를 참고하시거나 본인이 가지고 계신 아이콘을 사용하셔도 무방합니다.
홈 글쓰기 페이지 내 네이게이션도 추가해줍니다.
@Composable
fun HomeWritePage(actions: PageMoveActions, navBackStackEntry: NavBackStackEntry) {
val coroutineScope: CoroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val viewModel: HomeWriteViewModel = viewModel(
factory = HiltViewModelFactory(context, navBackStackEntry)
)
HomeWriteScreen(
state = viewModel.viewState.value,
effectFlow = viewModel.effect,
onEventSent = { event -> viewModel.setEvent(event) },
onNavigationRequested = { navigationEffect ->
coroutineScope.launch {
when (navigationEffect) {
is HomeWriteContract.Effect.Navigation.GoToBack -> actions.upPress.invoke()
}
}
}
)
}
위와 같이 작업을 하고 나면 다음과 같은 화면을 확인 가능합니다.
코드를 실행하면 다음과 같습니다.
이 작업까지는 아직 데이터가 저장되지는 않습니다.
다음 8편에서는 이 디자인에 입력된 데이터 들을 직접 저장하고 조회하는 기능을 추가하여 기능을 완성해볼 예정입니다.
https://victorywskim.tistory.com/69
전체 코드는 다음과 같습니다.
감사합니다.