Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature/week_1_compose]: 1주차 과제 Compose 구성 #8

Open
wants to merge 3 commits into
base: develop_compose
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .idea/inspectionProfiles/Project_Default.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 28 additions & 3 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ android {
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}

buildTypes {
Expand All @@ -24,11 +27,17 @@ android {
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '11'
jvmTarget = '17'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.5"
}
}

Expand All @@ -38,7 +47,23 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.5.1'
implementation platform('androidx.compose:compose-bom:2022.11.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'

// Navigation
implementation 'androidx.navigation:navigation-compose:2.5.3'


testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
}
6 changes: 4 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
android:theme="@style/Theme.DoSoptTemplate"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
android:name="org.sopt.dosopttemplate.MainActivity"
android:exported="true"
android:label="@string/title_activity_main"
android:theme="@style/Theme.DoSoptTemplate">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

Expand Down
89 changes: 85 additions & 4 deletions app/src/main/java/org/sopt/dosopttemplate/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,92 @@
package org.sopt.dosopttemplate

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.sopt.dosopttemplate.feature.home.HomeScreen
import org.sopt.dosopttemplate.feature.login.LoginScreen
import org.sopt.dosopttemplate.feature.signup.SignUpScreen
import org.sopt.dosopttemplate.ui.theme.DoEuijinKwakTheme

class MainActivity : ComponentActivity() {

enum class Screen {
LOGIN,
SIGNUP,
Home,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Home,
HOME;

}

private val viewModel by viewModels<MainViewModel>()

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setContent {
DoEuijinKwakTheme {
val navController = rememberNavController()
Comment on lines +31 to +37

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private val viewModel by viewModels<MainViewModel>()
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setContent {
DoEuijinKwakTheme {
val navController = rememberNavController()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
DoEuijinKwakTheme {
val navController = rememberNavController()
val viewModel: MainViewModel = viewModel()

컴포즈에서는 delegate 방식이 아닌 함수 내에서 변수 선언방식으로 뷰모델을 불러올 수도 있습니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 처음알았네요 그러면 저 viewmodel은 어떤 생명주기를 가지고있나요??

NavHost(
navController = navController,
startDestination = Screen.LOGIN.name,
) {
composable(Screen.LOGIN.name) {
LoginScreen(
navController = navController,
viewModel = viewModel,
)
}
composable(Screen.SIGNUP.name) {
SignUpScreen(
navController =
navController,
viewModel = viewModel,
)
}
composable(Screen.Home.name) {
HomeScreen(
navController = navController,
viewModel = viewModel,
Comment on lines +44 to +58

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여러개의 화면에서 하나의 ViewModel을 공유하면 한 화면의 상태를 다른화면도 알 수 있는 상황도 있을 것 같네요. 괜찮긴 하지만 굳이 이걸 다 알아야하나라는 생각도 있습니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞습니다... 근데 보통 Auth관련 로직은 AppViewModel에 넣고 처리하는게 일반적인 방법이라고 생각되어 위처럼 구성해보았습니다

)
}
}
collectEvent(navController)
}
}
}
}

private fun collectEvent(navController: NavHostController) {
viewModel.event.flowWithLifecycle(lifecycle).onEach {
when (it) {
is MainContract.MainSideEffect.ShowToast -> {
Toast.makeText(this, it.message, Toast.LENGTH_SHORT).show()
}

is MainContract.MainSideEffect.LoginSuccess -> {
Toast.makeText(this, "로그인 성공!", Toast.LENGTH_SHORT).show()
navController.navigate(Screen.Home.name)
}

is MainContract.MainSideEffect.RegistrationSuccess -> {
Toast.makeText(this, "회원가입 성공!", Toast.LENGTH_SHORT).show()
navController.popBackStack() // 로그인 화면으로 돌아갑니다.
}
}
}.launchIn(lifecycleScope)
Comment on lines +68 to +84

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 방식은 주로 View에서 많이 사용하는 방식이고 Compose에서는 상태를 구독받을 때

    val state by viewModel.state.collectStateWithLifecycle()

로 구독을 많이 받습니다. (그리고 이벤트도 거의 안쓰이고 대부분 UiState 선에서 다 해결할 수 있습니다.)

참고로 지난 DroidKaigi 샘플앱에서도 SharedFlow를 사용한 코드는 없었네요.

}
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
DoEuijinKwakTheme {}
}
17 changes: 17 additions & 0 deletions app/src/main/java/org/sopt/dosopttemplate/MainContract.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.sopt.dosopttemplate

class MainContract {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굳이 클래스로 한번더 wrapping한 이유가 있나요?

data class MainState(
val id: String = "",
val registerId: String = "",
val pw: String = "",
val registerPw: String = "",
val nickname: String = "",
)

sealed interface MainSideEffect {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SideEffect는 보통 악영향, 예상치 못한 문제 이런곳에 사용하는 네이밍 아닌가요? 인터페이스 이름을 MainSideEffect로 하신 이유를 알고 싶습니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의학용어에서는 그렇게 사용하긴 하지만 CS에서 사이드 이펙트라는 것은 어떤 행위에 대한 부수 효과(의도치 않는 생산물?) 정도로 받아들이면 좋을 것 같네요. 함수형 프로그래밍에서 자주 다뤄지는 개념입니다 (순수함수와 사이드 이펙트라는 용어로 말이죠)

data class ShowToast(val message: String) : MainSideEffect
object LoginSuccess : MainSideEffect
object RegistrationSuccess : MainSideEffect
}
}
60 changes: 60 additions & 0 deletions app/src/main/java/org/sopt/dosopttemplate/MainViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package org.sopt.dosopttemplate

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class MainViewModel : ViewModel() {
private val _state = MutableStateFlow(MainContract.MainState())
val state get() = _state.asStateFlow()

private val _event = MutableSharedFlow<MainContract.MainSideEffect>()
val event get() = _event.asSharedFlow()

private fun emitToast(message: String) {
viewModelScope.launch { _event.emit(MainContract.MainSideEffect.ShowToast(message)) }
}

fun updateState(newState: MainContract.MainState) {
_state.value = newState
}

fun login() {
val state = state.value
when {
state.id.isEmpty() || state.id != state.registerId -> emitToast("아이디를 확인해주세요.")
state.pw.isEmpty() || state.pw != state.registerPw -> emitToast("비밀번호를 확인해주세요.")
else -> viewModelScope.launch {
_event.emit(MainContract.MainSideEffect.LoginSuccess)
}
}
}

fun register() {
val id = state.value.registerId
val pw = state.value.registerPw
val nickname = state.value.nickname

when {
id.length !in 6..10 -> emitToast("ID는 6~10 글자로 입력해주세요.")
pw.length !in 8..12 -> emitToast("비밀번호는 8~12 글자로 입력해주세요.")
nickname.isBlank() -> emitToast("닉네임을 입력해주세요.")
else -> completeRegistration(id, pw, nickname)
}
}
Comment on lines +26 to +48

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

잘 생각을 해보면...String의 길이 비교나 id, password 일치 조건등의 비교등이 뷰모델에서 가지는 역할이라고 볼 수 있을까요?


fun clearRegisterInfo() {
updateState(state.value.copy(registerId = "", registerPw = "", nickname = ""))
}

private fun completeRegistration(id: String, pw: String, nickname: String) {
viewModelScope.launch {
updateState(state.value.copy(id = id, pw = pw, nickname = nickname))
_event.emit(MainContract.MainSideEffect.RegistrationSuccess)
}
}
}
101 changes: 101 additions & 0 deletions app/src/main/java/org/sopt/dosopttemplate/feature/home/HomeScreen.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package org.sopt.dosopttemplate.feature.home

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import org.sopt.dosopttemplate.MainViewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
navController: NavController,
viewModel: MainViewModel,
) {
Scaffold { innerPadding ->
HomeContent(
modifier = modifier.padding(innerPadding),
navController = navController,
viewModel = viewModel,
)
}
}

@Composable
fun HomeContent(
modifier: Modifier,
navController: NavController,
viewModel: MainViewModel,
) {
Comment on lines +43 to +48

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@Composable
fun HomeContent(
modifier: Modifier,
navController: NavController,
viewModel: MainViewModel,
) {
@Composable
fun HomeContent(
modifier: Modifier = Modifier,
navController: NavController,
viewModel: MainViewModel,
) {

+) 이렇게 작성하면 Preview를 그릴 수 없겠죠? 어떻게 하면 HomeContent의 Preview를 그릴 수 있게 만들 수 있을까요?

val state = viewModel.state.collectAsState()
Copy link

@l2hyunwoo l2hyunwoo Nov 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val state = viewModel.state.collectAsState()
val state by viewModel.state.collectAsStateWithLifecycle()

Column(
modifier = modifier
.padding(horizontal = 16.dp)
.padding(top = 32.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = "Person Icon",
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = state.value.nickname,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
text = state.value.nickname,
text = state.nickname,

collect를 할 때 by 문법을 활용하면 바로 value에 접근할 수 있습니다.

style = TextStyle(
fontWeight = FontWeight(700),
fontSize = 20.sp,
),
color = Color.Black,
)
Spacer(modifier = Modifier.size(16.dp))
Text(
text = "컴포즈 반가워",
style = TextStyle(
fontSize = 20.sp,
),
color = Color.Black,
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "ID",
style = TextStyle(
fontWeight = FontWeight(700),
fontSize = 30.sp,
),
)
Spacer(modifier = Modifier.height(8.dp))
Text(text = state.value.id)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "PW",
style = TextStyle(
fontWeight = FontWeight(700),
fontSize = 30.sp,
),
)
Spacer(modifier = Modifier.height(8.dp))
Text(text = state.value.pw)
}
}
Loading