티스토리 뷰
2022.06.29 - [안드로이드] - Compose 기초 1 : Column, Row, Box
2022.06.30 - [안드로이드] - Compose 기초 2 : Text, Image, LazyColumn + Card
이전 두 개의 글을 통해 기본적인 사용법을 익혔으니 간단하게 미니 프로젝트를 만들어 봅니다.
※ 아직 접한지 며칠 안되었고 다른 분들은 어떻게 짜는지 보지 않아서 많이 부족한 글입니다.
또한 다른 기술이 얼마나 있는지 모르는 상태에서 해본 거라 불필요한 내용이 있을 수 있는 점 감안해서 가볍게 봐주세요~ㅎㅎ
어떤 걸 가볍게 만들어 볼까 생각하다가 마침 포켓몬 API도 있겠다 포켓몬 도감을 만들기로 하였습니다.
0. 준비
1) UI 준비
해당 도감을 콘셉트로 UI를 만들어 보려고 합니다.
우선 사용할 디자인을 Figma를 통해 만들어주었습니다.
사각형과 원만 이용해서 만들 수 있어서 어렵지 않게 만들 수 있었네요 (복잡하면 제가 못 만들어서..ㅎㅎ)
2) app수준 build.gradle
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'kotlin-parcelize'
id 'dagger.hilt.android.plugin'
}
android {
compileSdk 32
defaultConfig {
applicationId "com.example.pokedex"
minSdk 21
targetSdk 32
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.8.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation 'androidx.compose.material3:material3:1.0.0-alpha13'
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
implementation 'androidx.activity:activity-compose:1.4.0'
implementation "com.google.accompanist:accompanist-systemuicontroller:0.17.0"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
def lifecycle_version = "2.4.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
// hilt
implementation "com.google.dagger:hilt-android:2.38.1"
kapt "com.google.dagger:hilt-android-compiler:2.38.1"
implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
def retrofit_version = '2.9.0'
def okhttp3_version = '4.9.1'
// retrofit - http://square.github.io/retrofit/ (Apache 2.0)
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
// okhttp - https://github.com/square/okhttp (Apache 2.0)
implementation "com.squareup.okhttp3:okhttp:$okhttp3_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp3_version"
// Glide
implementation 'com.github.bumptech.glide:glide:4.13.2'
annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0'
// https://github.com/skydoves/landscapist
implementation "com.github.skydoves:landscapist-glide:1.5.2"
}
3) project 수준의 build.gradle
buildscript {
ext {
compose_version = '1.1.0-beta01'
}
dependencies {
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.40.1'
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.2.0' apply false
id 'com.android.library' version '7.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.5.31' apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}
이렇게 초기 설정을 마무리합니다.
1. Hilt, ViewModel, Repository, Retrofit
2022.04.28 - [안드로이드/코드] - 도로명 주소 개발센터 API 사용해보기 1 : Api활용 신청, Retrofit
Hilt, ViewModel, Repository, Retrofit 관련 글은 위 시리즈에서 자세히 다루었으므로 대략적으로 작성하고 넘어가겠습니다.
object Constants {
const val BASE_URL = "https://pokeapi.co/api/"
fun getDetailImage(index: Int) : String =
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${index}.png"
fun getDotImage(index: Int) : String =
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${index}.png"
fun getShinyImage(index: Int) : String =
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/shiny/${index}.png"
fun Context.makeToast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
}
이미지의 경우 Index값만 바꾸면 되는 정보라서 api에서 받을 필요가 없었습니다.
@HiltAndroidApp
class Pokedex : Application() {
companion object {
private lateinit var application : Pokedex
fun getInstance() : Pokedex = application
}
override fun onCreate() {
super.onCreate()
application = this
}
}
@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
@Provides
@Singleton
fun provideLoggingInterceptor() : HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
@Provides
@Singleton
fun provideOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor
) : OkHttpClient =
OkHttpClient.Builder()
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.addInterceptor(loggingInterceptor)
.build()
@Provides
@Singleton
fun provideGsonConvertFactory() : GsonConverterFactory = GsonConverterFactory.create()
@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient,
gsonConverterFactory: GsonConverterFactory
) : Retrofit =
Retrofit.Builder()
.baseUrl(Constants.BASE_URL)
.client(okHttpClient)
.addConverterFactory(gsonConverterFactory)
.build()
@Provides
@Singleton
fun providePokemonService(
retrofit: Retrofit
) : PokemonService =
retrofit.create(PokemonService::class.java)
}
Hilt에서 Retrofit을 사용하기 위한 모듈
Api는 Pokeapi를 사용하였습니다. 별도 활용 신청이 필요하지 않고 바로 사용 가능합니다.
다만 다 영어로 되어있어서 포켓몬 이름은 제가 그냥 하드 코딩해서 넣을 예정입니다.
interface PokemonService {
@GET("v2/pokemon/{index}")
suspend fun getPokemonDetail(
@Path("index") index: Int = 1
) : Response<PokemonInfo>
}
다른 것들은 필요 없고 해당 포켓몬의 정보만 있으면 돼서 한 개의 Api만 사용하였습니다.
data class PokemonInfo(
val stats : List<PokemonStatus>?,
val types : List<PokemonType>?
)
data class PokemonStatus(
@SerializedName("base_stat")
val baseStat : Int?,
val stat : StatusName?,
)
data class StatusName(
val name : String?,
val url : String?
)
data class PokemonType(
val slot : Int?,
val type : TypeName?
)
data class TypeName(
val name : String?,
val url : String?
)
위의 Api에서 많은 정보들을 제공해 주지만 저는 타입과 스테이터스 정보만 받아왔습니다. (다만 타입은 아직 UI반영을 안 했습니다.)
data class PokemonInfoResult(
val status : Map<String, Int>,
val typeList : List<String>
)
fun PokemonStatus.mapper() : Pair<String, Int>? {
baseStat ?: return null
stat?.name ?: return null
return Pair(getStatusKoreanName(stat.name), baseStat)
}
fun PokemonType.mapper() : String? {
type?.name?.let {
return it
} ?: return null
}
fun PokemonInfo.mapper() : PokemonInfoResult {
val status = mutableMapOf<String, Int>()
val typeList = mutableListOf<String>()
stats?.mapNotNull { it.mapper() }?.forEach {
status[it.first] = it.second
}
types?.mapNotNull { it.mapper() } ?.let {
typeList.addAll(it)
}
return PokemonInfoResult(
status = status,
typeList = typeList
)
}
Repository에서 mapper를 하기 위한 함수와 결과를 담을 data class를 만들어 주었습니다.
class PokemonClient @Inject constructor(
private val service: PokemonService
) {
suspend fun getPokemonInfo(
index: Int,
onSuccessListener : (PokemonInfo) -> Unit,
onFailureListener : () -> Unit
) =
try {
val result = service.getPokemonDetail(index = index)
if (result.isSuccessful) {
result.body()?.let(onSuccessListener)?: onFailureListener()
} else {
onFailureListener()
}
} catch (e: Exception) {
e.printStackTrace()
onFailureListener()
}
}
class PokemonRepository @Inject constructor(
private val client: PokemonClient
) {
suspend fun getPokemonInfo(
index : Int,
onSuccessListener: (PokemonInfoResult) -> Unit,
onFailureListener: () -> Unit
) {
client.getPokemonInfo(
index = index,
onSuccessListener = {
onSuccessListener(it.mapper())
},
onFailureListener = onFailureListener
)
}
}
@HiltViewModel
class MainViewModel @Inject constructor(
private val repository: PokemonRepository
) : ViewModel() {
val pokomonInfoFlow = MutableStateFlow(PokemonInfoResult(emptyMap(), emptyList()))
fun getPokemonInfo(
index: Int
) = viewModelScope.launch {
repository.getPokemonInfo(
index = index,
onSuccessListener = {
pokomonInfoFlow.value = it
},
onFailureListener = {
}
)
}
}
client, repository, viewModel입니다.
실패했을 때는 나중에 업데이트할 때 추가할 예정입니다.
MutableStateFlow 관련해서는 아래에서 설명하겠습니다.
그다음은 영어로 된 정보를 한글로 전환하기 위한 데이터입니다.
enum class Status(
val originalName: String,
val koreanName: String,
val color: Color
) {
HP(originalName = "hp", koreanName = "HP", Color(0xFFEF5350)),
ATTACK(originalName = "attack", koreanName = "공격", Color(0xFFFF7043)),
DEFENSE(originalName = "defense", koreanName = "방어", Color(0xFFFFCA28)),
SPECIAL_ATTACK(originalName = "special-attack", koreanName = "특수공격", Color(0xFF42A5F5)),
SPECIAL_DEFENSE(originalName = "special-defense", koreanName = "특수방어", Color(0xFF66BB6A)),
SPEED(originalName = "speed", koreanName = "스피드", Color(0xFFEC407A));
}
fun getStatusKoreanName(originalName: String) =
Status.values().firstOrNull { it.originalName == originalName }?.koreanName ?: "HP"
fun getStatusColor(koreanName: String) =
Status.values().firstOrNull { it.koreanName == koreanName }?.color ?: Color(0xffffffff)
fun getPokemonList() = listOf(
"이상해씨", "이상해풀", "이상해꽃",
"파이리", "리자드", "리자몽",
"꼬부기", "어니부기", "거북왕",
"캐터피", "단데기", "버터플",
"뿔충이", "딱충이", "독침붕",
"구구", "피죤", "피죤투",
"꼬렛", "레트라",
"깨비참", "깨비드릴조",
"아보", "아보크",
"피카츄", "라이츄",
"모래두지", "고지",
"니드런♀", "니드리나", "니드퀸",
"니드런♂", "니드리노", "니드킹",
"삐삐", "픽시",
"식스테일", "나인테일",
"푸린", "푸크린",
"주뱃", "골뱃",
"뚜벅초", "냄새꼬", "라플레시아",
"파라스", "파라섹트",
"콘팡", "도나리",
"디그다", "닥트리오",
"나옹", "페르시온",
"고라파덕", "골덕",
"망키", "성원숭",
"가디", "윈디",
"발챙이", "수륙챙이", "강챙이",
"캐이시", "윤겔라", "후딘",
"알통몬", "근육몬", "괴력몬",
"모다피", "우츠동", "우츠보트",
"왕눈해", "독파리",
"꼬마돌", "데구리", "딱구리",
"포니타", "날쌩마",
"야돈", "야도란",
"코일", "레어코일",
"파오리",
"두두", "두투리오",
"쥬쥬", "쥬레곤",
"질퍽이", "찔뻐기",
"셀러", "파르셀",
"고오스", "고우스트", "팬텀",
"롱스톤",
"슬리프", "슬리퍼",
"크랩", "킹크랩",
"찌리리공", "붐볼",
"아라리", "나시",
"탕구리", "텅구리",
"시라소몬", "홍수몬",
"내루미",
"또가스", "또도가스",
"뿔카노", "코뿌리",
"럭키",
"덩쿠리",
"캥카",
"쏘드라", "시드라",
"콘치", "왕콘치",
"별가사리", "아쿠스타",
"마임맨",
"스라크",
"루주라",
"에레브",
"마그마",
"쁘사이저",
"켄타로스",
"잉어킹",
"갸라도스",
"라프라스",
"메타몽",
"이브이", "샤미드", "쥬피선더", "부스터",
"폴리곤",
"암나이트", "암스타",
"투구", "투구푸스",
"프테라",
"잠만보",
"프리져", "썬더", "파이어",
"미뇽", "신뇽", "망나뇽",
"뮤츠", "뮤"
)
2. Main Ui
드디어 Compose 시작입니다.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PokedexTheme {
NavigationGraph()
}
}
}
}
이번 프로젝트에서는 내비게이션을 이용해볼 생각입니다.
구성은 메인화면과 상세화면 두 개입니다.
내비게이션 관련해서는 4번에서 다룰 예정입니다.
@Composable
fun MainContainer(routeAction: RouteAction) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box {
LazyColumn(
modifier = Modifier
.background(SubColor)
.fillMaxSize(),
contentPadding = PaddingValues(vertical = 137.dp)
) {
itemsIndexed(getPokemonList()) { index, item ->
PokemonItem(index + 1, item, routeAction)
}
}
DexImage(R.drawable.ic_dex_top)
DexImage(
res = R.drawable.ic_dex_bottom,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
@Composable
fun DexImage(@DrawableRes res: Int, modifier: Modifier = Modifier) {
Image(
painter = painterResource(id = res),
contentDescription = "dex image",
contentScale = ContentScale.FillWidth,
modifier = modifier.fillMaxWidth()
)
}
상, 하단에 있는 Image에 들어갈 내용이 res 빼고는 똑같기 때문에 res를 받아서 Image를 그리도록 함수를 만들었습니다.
@Composable
fun PokemonItem(index: Int, name: String, routeAction: RouteAction, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(25.dp, 5.dp)
.clickable { routeAction.navToDetail(index, name) },
verticalAlignment = Alignment.CenterVertically
) {
GlideImage(
imageModel = getDotImage(index),
loading = {
Image(
painter = painterResource(id = R.drawable.ic_ball),
contentDescription = null,
modifier = Modifier.size(30.dp).align(Alignment.Center)
)
},
failure = {
},
modifier = modifier.size(70.dp)
)
Spacer(modifier = Modifier.size(20.dp))
Text(
text = "No.${index.toString().padStart(3, '0')}",
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = name,
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
)
}
}
LazyColumn에서 반복되는 아이템에서는
Glide를 통해 도트 이미지를 불러오고 도감 번호와 이름이 출력되도록 하였습니다.
메인 화면 UI입니다.
3. Detail UI
참고로 파일 나누는 건 저는 Main 관련은 MainActivity에 두고, 상세화면은 PokemonDetail.kt에 내비게이션 관련은 Navigation.kt 파일로 나누었습니다.
안 나누고 해도 무관하나 나중에 수정할 때 저렇게 하는 편이 좋지 않을까 싶어서 우선 저렇게 해두었습니다.
@Composable
fun DetailContainer(index: Int, name: String, viewModel: MainViewModel = hiltViewModel()) {
val pokemonInfo by viewModel.pokomonInfoFlow.collectAsState()
val scrollState = rememberScrollState()
viewModel.getPokemonInfo(index)
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box {
Column(
modifier = Modifier
.background(SubColor)
.fillMaxSize()
.verticalScroll(scrollState)
.padding(0.dp, 137.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PokemonInfoImage(index, name)
PokemonStatus(pokemonInfo)
PokemonShinyImage(index)
}
DexImage(R.drawable.ic_dex_top)
DexImage(
res = R.drawable.ic_dex_bottom,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
구성은 Main과 거의 유사합니다.
이전 2개의 글에 없었던 내용이 3가지가 있습니다.
fun DetailContainer(index: Int, name: String, viewModel: MainViewModel = hiltViewModel())
우선 Hilt로 만든 viewModel을 추가는 방법입니다.
위에서 build.gradle에 아래의 내용을 잘 추가되었다면 정상적으로 작동할 것입니다.
fun DetailContainer(index: Int, name: String, viewModel: MainViewModel = hiltViewModel())
Activity에 @AndroidEntryPoint 달아주는 것도 잊지 말아 주세요~
이렇게 하면 해당 Composable 함수에서 viewmodel을 사용할 수 있습니다.
두 번째는 ScrollState입니다.
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.background(SubColor)
.fillMaxSize()
.verticalScroll(scrollState)
.padding(0.dp, 137.dp),
horizontalAlignment = Alignment.CenterHorizontally,
)
스크롤 상태를 저장하여 Column에게 전달해 주면 스크롤이 가능해집니다.
Row 역시 똑같이 적용해주시면 됩니다.
세 번째는 MutableStateFlow입니다.
val pokomonInfoFlow = MutableStateFlow(PokemonInfoResult(emptyMap(), emptyList()))
위에서 ViewModel에서 나왔던 변수입니다.
val pokemonInfo by viewModel.pokomonInfoFlow.collectAsState()
그걸 collectAsState로 받아서 변수에 저장하는 것입니다.
Compose는 Xml때처럼 textView.text = ""로 변경하는 방식이 아니고
State변수에 값을 저장해두었다가 값이 변경되었을 때 해당 값을 이용하는 UI를 다시 그리는 방식으로 동작하게 됩니다.
@Composable
fun PokemonInfoImage(index: Int, name: String) {
val infiniteTransition = rememberInfiniteTransition()
val rotationAngle = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 4000,
delayMillis = 0,
easing = FastOutLinearInEasing
),
repeatMode = RepeatMode.Reverse
),
)
val circleSize by infiniteTransition.animateFloat(
initialValue = 100.0f,
targetValue = 300.0f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
delayMillis = 0,
easing = FastOutLinearInEasing
),
repeatMode = RepeatMode.Reverse
)
)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(Color(0x805282F7))
.size(circleSize.dp)
.align(Alignment.Center)
)
Image(
painter = painterResource(id = R.drawable.img_character_effect),
contentDescription = "circle",
modifier = Modifier
.size(360.dp)
.rotate(rotationAngle.value)
)
GlideImage(
imageModel = getDetailImage(index),
loading = {
CircularProgressIndicator()
},
failure = {
Text(text = "이미지 로드에 실패하였습니다.")
},
modifier = Modifier.padding(55.dp)
)
Text(
modifier = Modifier.padding(20.dp, 25.dp)
.align(Alignment.TopStart),
text = name,
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
)
}
}
포켓몬의 이름과, 이미지를 보여주는 함수입니다.
여기서는 애니메이션을 적용을 해 보았습니다.
두 가지 애니메이션이 적용되었습니다.
위의 이미지를 빙글빙글 돌리는 것과 파란 원을 커졌다 작아졌다 하는 애니메이션입니다.
val infiniteTransition = rememberInfiniteTransition()
val rotationAngle = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 4000,
delayMillis = 0,
easing = FastOutLinearInEasing
),
repeatMode = RepeatMode.Reverse
),
)
rememberInfiniteTransition()은 무한반복 상태를 저장합니다.
rotationAngle은 이미지 회전의 각도를 정하는 변수입니다.
4초 동안 0도에서 360도로 반복하게 되고 RepeatMode.Reverse이므로 0도 -> 360도로 한 바퀴 돌고 역방향으로 한 바퀴를 돌게 됩니다.
만약 RepeatMode.Reverse를 안 한다고 하면 한 방향으로만 계속 반복해서 돌게 될 것입니다.
https://developer.android.com/jetpack/compose/animation?hl=ko
애니메이션 종류 같은 경우는 위에 링크를 참조해주세요
val circleSize by infiniteTransition.animateFloat(
initialValue = 100.0f,
targetValue = 300.0f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
delayMillis = 0,
easing = FastOutLinearInEasing
),
repeatMode = RepeatMode.Reverse
)
)
두 번째 애니메이션은 원의 크기를 조절하여 커졌다 작아졌다 반복하는 애니메이션입니다.
@Composable
fun StatusBar(name: String, status: Float, color: Color, modifier: Modifier = Modifier) {
Row(modifier = Modifier.fillMaxWidth()) {
var isRotated by rememberSaveable { mutableStateOf(false) }
val rotationAngle by animateFloatAsState(
targetValue = if (isRotated) status / 150 else 0.0f,
animationSpec = tween(durationMillis = 2500)
)
val onClickAction = remember(Unit) {
{
isRotated = !isRotated
}
}
LaunchedEffect(Unit) {
onClickAction()
}
Text(
text = name,
modifier = Modifier.weight(3f)
)
LinearProgressIndicator(
modifier = modifier
.height(20.dp)
.weight(10f)
.clip(RoundedCornerShape(20.dp)),
progress = rotationAngle,
color = color
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "${status.toInt()}",
modifier = Modifier
.weight(2f),
textAlign = TextAlign.Center
)
}
}
스테이터스를 나타내는 UI입니다.
LinearProgressIndicator는 선형 ProgressBar와 유사합니다..
역시 애니메이션을 적용할 건데 이번에는 무한 반복이 아닌 실행 시에 한 번만 작동하게 설정할 예정입니다.
제가 아직 못 찾은 것일 수도 있는데 자동으로 한 번만 동작하게 하는 방법을 알아내지 못하였습니다.
모든 예제가 버튼 클릭으로 되어있어서 자동 클릭? 방향으로 구현을 하였습니다.
var isRotated by rememberSaveable { mutableStateOf(false) }
val rotationAngle by animateFloatAsState(
targetValue = if (isRotated) status / 150 else 0.0f,
animationSpec = tween(durationMillis = 2500)
)
val onClickAction = remember(Unit) {
{
isRotated = !isRotated
}
}
isRotated는 눌렸는지 안 눌렸는지 상태 값을 저장합니다.
rotationAngle은 눌리기 전에는 0을 눌린 후에는 해당 값을 가지고 있고, 애니메이션 시간은 2.5초입니다.
onClickAction은 isRotated를 반전시키는 함수입니다.
LaunchedEffect(Unit) {
onClickAction()
}
마지막으로 LaunchedEffect를 통해 onClickAction을 동작시키게 하면 애니메이션이 동작하게 됩니다.
LaunchedEffect에 대해서는 다른 글에서 자세히 알아보겠습니다.
우선 LaunchedEffect안에 Unit 또는 true와 같이 변하지 않는 값을 넣어주면
Composable함수의 생성과 동시에 실행하게 됩니다.
4. Navigation
class RouteAction(navHostController: NavHostController) {
val navToDetail : (Int, String) -> Unit = { index, name ->
navHostController.navigate("$DETAIL/$index/$name")
}
companion object {
const val HOME = "home"
const val DETAIL = "detail"
}
}
우선 상태 이동에 관련 함수를 정의해줄 클래스를 생성해 줍니다.
@Composable
fun NavigationGraph() {
val navController = rememberNavController()
val routeAction = remember(navController) {
RouteAction(navController)
}
NavHost(navController = navController, startDestination = RouteAction.HOME) {
composable(RouteAction.HOME) {
MainContainer(routeAction)
}
composable(
route = "${RouteAction.DETAIL}/{index}/{name}",
arguments = listOf(
navArgument("index") { type = NavType.IntType },
navArgument("name") { type = NavType.StringType }
)
) { entry ->
val index = entry.arguments?.getInt("index")
val name = entry.arguments?.getString("name")
if (index == null || name == null) {
LocalContext.current.makeToast("오류가 발생하였습니다.")
return@composable
}
DetailContainer(index, name)
}
}
}
val navController = rememberNavController()
val routeAction = remember(navController) {
RouteAction(navController)
}
우선 navController를 생성해 준 뒤 위에서 만든 RouteAction도 같이 만들어 줍니다.
NavHost(navController = navController, startDestination = RouteAction.HOME)
그다음 NavHost를 만들어 주는데 navController와 시작할 화면의 값을 넣어줍니다.
composable(RouteAction.HOME) {
MainContainer(routeAction)
}
composable(
route = "${RouteAction.DETAIL}/{index}/{name}",
arguments = listOf(
navArgument("index") { type = NavType.IntType },
navArgument("name") { type = NavType.StringType }
)
) { entry ->
val index = entry.arguments?.getInt("index")
val name = entry.arguments?.getString("name")
if (index == null || name == null) {
LocalContext.current.makeToast("오류가 발생하였습니다.")
return@composable
}
DetailContainer(index, name)
}
composable() 안에는 Route 이름이 들어가게 됩니다.
메인 화면에서는 메인 화면의 UI를 담아둔 MainContainer와 routeAction을 넘겨주게 됩니다.
composable(
route = "${RouteAction.DETAIL}/{index}/{name}",
arguments = listOf(
navArgument("index") { type = NavType.IntType },
navArgument("name") { type = NavType.StringType }
)
상세화면은 메인화면을 통해서 오게 되는데 특정 값을 넘겨주어야 합니다.
그래서 route 값에 {}로 감싸고 이름을 넣어주시고 arguments에서는 위에서 만든 이름과 동일하게 설정하고 타입을 지정해줍니다.
composable(
...
) { entry ->
val index = entry.arguments?.getInt("index")
val name = entry.arguments?.getString("name")
if (index == null || name == null) {
LocalContext.current.makeToast("오류가 발생하였습니다.")
return@composable
}
DetailContainer(index, name)
}
그 다음 arguments에서 받은 값을 받아와서 DetailContainer를 만드는 데 사용할 수 있습니다.
Main화면 쪽에서는
routeAction.navToDetail(index, name)
받은 routeAction을 통해 화면 전환을 시도합니다.
결과 화면입니다.
Compose 전체 코드
@Composable
fun NavigationGraph() {
val navController = rememberNavController()
val routeAction = remember(navController) {
RouteAction(navController)
}
NavHost(navController = navController, startDestination = RouteAction.HOME) {
composable(RouteAction.HOME) {
MainContainer(routeAction)
}
composable(
route = "${RouteAction.DETAIL}/{index}/{name}",
arguments = listOf(
navArgument("index") { type = NavType.IntType },
navArgument("name") { type = NavType.StringType }
)
) { entry ->
val index = entry.arguments?.getInt("index")
val name = entry.arguments?.getString("name")
if (index == null || name == null) {
LocalContext.current.makeToast("오류가 발생하였습니다.")
return@composable
}
DetailContainer(index, name)
}
}
}
class RouteAction(navHostController: NavHostController) {
val navToDetail : (Int, String) -> Unit = { index, name ->
navHostController.navigate("$DETAIL/$index/$name")
}
companion object {
const val HOME = "home"
const val DETAIL = "detail"
}
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PokedexTheme {
NavigationGraph()
}
}
}
}
@Composable
fun MainContainer(routeAction: RouteAction) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box {
LazyColumn(
modifier = Modifier
.background(SubColor)
.fillMaxSize(),
contentPadding = PaddingValues(vertical = 137.dp)
) {
itemsIndexed(getPokemonList()) { index, item ->
PokemonItem(index + 1, item, routeAction)
}
}
DexImage(R.drawable.ic_dex_top)
DexImage(
res = R.drawable.ic_dex_bottom,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
@Composable
fun DexImage(@DrawableRes res: Int, modifier: Modifier = Modifier) {
Image(
painter = painterResource(id = res),
contentDescription = "dex image",
contentScale = ContentScale.FillWidth,
modifier = modifier.fillMaxWidth()
)
}
@Composable
fun PokemonItem(index: Int, name: String, routeAction: RouteAction, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(25.dp, 5.dp)
.clickable { routeAction.navToDetail(index, name) },
verticalAlignment = Alignment.CenterVertically
) {
GlideImage(
imageModel = getDotImage(index),
loading = {
Image(
painter = painterResource(id = R.drawable.ic_ball),
contentDescription = null,
modifier = Modifier.size(30.dp).align(Alignment.Center)
)
},
failure = {
},
modifier = modifier.size(70.dp)
)
Spacer(modifier = Modifier.size(20.dp))
Text(
text = "No.${index.toString().padStart(3, '0')}",
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = name,
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
)
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
PokedexTheme {
NavigationGraph()
}
}
@Composable
fun DetailContainer(index: Int, name: String, viewModel: MainViewModel = hiltViewModel()) {
val pokemonInfo by viewModel.pokomonInfoFlow.collectAsState()
val scrollState = rememberScrollState()
viewModel.getPokemonInfo(index)
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box {
Column(
modifier = Modifier
.background(SubColor)
.fillMaxSize()
.verticalScroll(scrollState)
.padding(0.dp, 137.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PokemonInfoImage(index, name)
PokemonStatus(pokemonInfo)
PokemonShinyImage(index)
}
DexImage(R.drawable.ic_dex_top)
DexImage(
res = R.drawable.ic_dex_bottom,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
@Composable
fun PokemonInfoImage(index: Int, name: String) {
val infiniteTransition = rememberInfiniteTransition()
val rotationAngle = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 4000,
delayMillis = 0,
easing = FastOutLinearInEasing
),
repeatMode = RepeatMode.Reverse
),
)
val circleSize by infiniteTransition.animateFloat(
initialValue = 100.0f,
targetValue = 300.0f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
delayMillis = 0,
easing = FastOutLinearInEasing
),
repeatMode = RepeatMode.Reverse
)
)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(Color(0x805282F7))
.size(circleSize.dp)
.align(Alignment.Center)
)
Image(
painter = painterResource(id = R.drawable.img_character_effect),
contentDescription = "circle",
modifier = Modifier
.size(360.dp)
.rotate(rotationAngle.value)
)
GlideImage(
imageModel = getDetailImage(index),
loading = {
CircularProgressIndicator()
},
failure = {
Text(text = "이미지 로드에 실패하였습니다.")
},
modifier = Modifier.padding(55.dp)
)
Text(
modifier = Modifier.padding(20.dp, 25.dp)
.align(Alignment.TopStart),
text = name,
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
)
}
}
@Composable
fun PokemonStatus(pokemonInfo: PokemonInfoResult) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(id = R.string.status),
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
)
for ((key, status) in pokemonInfo.status) {
StatusBar(key, status.toFloat(), getStatusColor(key))
}
}
}
@Composable
fun PokemonShinyImage(index: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp, 20.dp, 20.dp, 30.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(id = R.string.shiny),
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
)
GlideImage(
imageModel = getShinyImage(index),
loading = { CircularProgressIndicator() },
failure = {
Text(text = "이미지 로드에 실패하였습니다.")
},
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
@Composable
fun StatusBar(name: String, status: Float, color: Color, modifier: Modifier = Modifier) {
Row(modifier = Modifier.fillMaxWidth()) {
var isRotated by rememberSaveable { mutableStateOf(false) }
val rotationAngle by animateFloatAsState(
targetValue = if (isRotated) status / 150 else 0.0f,
animationSpec = tween(durationMillis = 2500)
)
val onClickAction = remember(Unit) {
{
isRotated = !isRotated
}
}
LaunchedEffect(Unit) {
onClickAction()
}
Text(
text = name,
modifier = Modifier.weight(3f)
)
LinearProgressIndicator(
modifier = modifier
.height(20.dp)
.weight(10f)
.clip(RoundedCornerShape(20.dp)),
progress = rotationAngle,
color = color
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "${status.toInt()}",
modifier = Modifier
.weight(2f),
textAlign = TextAlign.Center
)
}
}
'안드로이드 > 코드' 카테고리의 다른 글
포켓몬 도감 만들기(1) : Fast Api (0) | 2022.08.21 |
---|---|
Compose로 메모장 만들기 (0) | 2022.08.01 |
Compose 기초 2 : Text, Image, LazyColumn + Card (0) | 2022.06.30 |
네이버 검색 API 사용 (0) | 2022.06.09 |
데이터 바인딩 사용해보기 (0) | 2022.05.28 |
- Total
- Today
- Yesterday
- Compose Naver Map
- WorkManager
- column
- Kotlin
- Row
- compose
- Retrofit
- Compose 네이버 지도
- Compose QRCode Scanner
- Worker
- Compose BottomSheetScaffold
- Compose BottomSheetDialog
- Duplicate class fond 에러
- Compose ModalBottomSheetLayout
- 웹뷰
- LazyColumn
- 안드로이드 구글 지도
- Duplicate class found error
- 안드로이드
- Compose ConstraintLayout
- Compose BottomSheet
- 포켓몬 도감
- WebView
- Gradient
- Compose MotionLayout
- Compose 네이버 지도 api
- Pokedex
- Android
- Android Compose
- Fast api
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |