티스토리 뷰

728x90

2022.08.21 - [안드로이드/코드] - 포켓몬 도감 만들기(1) : Fast Api

 

포켓몬 도감 만들기(1) : Fast Api

Fast Api 설치 pip install fastapi 터미널에 위의 명령어를 입력하면 설치가 됩니다. 추가로 Python이 없을 경우 따로 설치해야 합니다. pip install "uvicorn[standrad]" 서버 작동을 위해서 uvicorn도 설치를..

alanboyce.tistory.com

이전 편에서 이어서 작성합니다.


홈 화면

@Composable
fun HomeScreen(routeAction: RouteAction) {
    val context = LocalContext.current
    LazyColumn(
        contentPadding = PaddingValues(vertical = 25.dp),
        modifier = Modifier.fillMaxSize()
    ) {
        item { HomeHeader() }
        item { Spacer(modifier = Modifier.height(25.dp)) }
        homeBody(routeAction, context)
    }
}

기존의 xml 방식에서는 화면을 분할해서 구현할 일은 많지 않았습니다.

저의 경우 Title Bar 영역은 보통 비슷한 형식으로 가는 경우가 많아서 따로 만들어서 include하였습니다.

그 외 CustomView나 RecyclerView등과 같은 경우가 아니라면 거의 한 파일에서 구현을 하였습니다.

그런데 Compose의 경우 선언 형식으로 구현을 하므로 코드를 분할하기 편리해졌습니다.

어떻게 구분을 해서 구현을 할까 고민하던 중 웹처럼 Header, Body, Footer로 구현하는 분의 코드를 보았습니다.

상, 중, 하로 나누어서 저도 이번 프로젝트에 한번 Header, Body, Footer로 구분을 두어서 코드 관리를 진행해 보았습니다.

홈 화면은 스크린샷을 보면 알 수 있지만 특별한 기능은 없는 화면입니다.

@Composable
fun HomeHeader() {
    /** 홈 화면 타이틀 **/
    Box(
        modifier = Modifier
            .fillMaxWidth()
    ) {
        Text(
            text = stringResource(id = R.string.pokedex),
            style = Typography.titleLarge,
            fontSize = 36.sp,
            color = MainColor,
            modifier = Modifier.align(Alignment.Center)
        )
    }
}

Header에서는 타이틀을 표시합니다.

fun LazyListScope.homeBody(
    routeAction: RouteAction,
    context: Context
) {
    // all ~ 9세대 버튼 이름 및 이미지 리스트
    val list = generationList

    /** 각 세대별 버튼 그리드 형식 표시 **/
    gridItems(
        data = list,
        columnCount = 2,
        horizontalArrangement = Arrangement.spacedBy(10.dp),
        modifier = Modifier.padding(horizontal = 16.dp)
    ) { itemData, _ ->
        HomeCardButton(itemData) {
            routeAction.navToList(it.replace(context.getString(R.string.generation), ""))
        }
    } // 각 세대별 버튼 그리드 형식 표시

    /** 포켓몬 등록, 진화 등록 버튼 **/
    item {
        Row(modifier = Modifier.padding(horizontal = 16.dp)) {
            /** 포켓몬 등록 버튼 **/
            HomeCardButton(
                data = Pair(context.getString(R.string.add_pokemon), R.drawable.img_add_pokemon),
                modifier = Modifier.weight(1f)
            ) {
                routeAction.navToAdd()
            } // 포켓몬 등록 버튼

            Spacer(modifier = Modifier.width(10.dp))

            /** 진화 등록 **/
            HomeCardButton(
                data = Pair(context.getString(R.string.add_evolution), R.drawable.img_add_evolution),
                modifier = Modifier.weight(1f)
            ) {
                routeAction.navToAddEvolution()
            } // 진화 등록
        } // Row
    } // 포켓몬 등록, 진화 등록 버튼
}

Body에서는 12개의 버튼을 그려줍니다.

gridItems는 LazyColumn의 LazyListScope 안에서 Grid 형식으로 UI를 구현하는 함수입니다. 링크에서 가져온 코드를 약간 수정하여 사용하고 있습니다.

gridItems를 통해 All과 1 ~ 9세대에 해당하는 버튼을 그리드 형식으로 버튼을 표시하고 클릭 시 해당 값을 가지고 상세 화면으로 이동하게 됩니다.

추가로 Row를 이용하여 포켓몬 등록, 진화 등록 버튼을 만들었습니다.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeCardButton(
    data: Pair<String, Int>,
    modifier: Modifier = Modifier,
    clickListener: (String) -> Unit
) {
    Card(
        colors = CardDefaults.elevatedCardColors(
            containerColor = getWhite(),
        ),
        onClick = {
            clickListener(data.first)
        },
        modifier = modifier
            .fillMaxWidth()
            .height(102.dp)
            .shadow(
                elevation = 6.dp,
                spotColor = getBlack(),
                shape = RoundedCornerShape(10.dp)
            )
    ) {
        Box(modifier = Modifier.fillMaxSize()) {
            Image(
                painter = painterResource(id = data.second),
                contentDescription = "image",
                modifier = Modifier
                    .width(151.dp)
                    .height(102.dp)
                    .align(Alignment.BottomEnd)
            )
            Text(
                text = data.first,
                fontSize = 20.sp,
                style = Typography.bodyLarge,
                color = MainColor,
                textAlign = TextAlign.Center,
                modifier = Modifier.padding(13.dp)
            )
        } // Box
    } // Card
}

이번에 사용하는 버튼은 내부 색상이 배경색과 동일하여 그림자 효과를 주지 않으면 버튼과 배경이 구분이 가지 않습니다.

그런데 Card의 Elevate를 사용하면 회색 그림자가 블랙 모드에서는 거의 안 보여서 무용지물이었습니다.

그래서 Modifer의 shadow를 이용하여 그림자의 크기, 생상, 모양 효과를 주어서 라이트 모드와 다크 모드 둘 다 shadow 효과를 볼 수 있게 되었습니다.


리스트 화면

1) API

@app.post("/pokemons/search")
async def read_search_pokemons(item: SearchInfo):
    query = session.query(PokemonTable.number, PokemonTable.name, PokemonTable.dotImage, PokemonTable.dotShinyImage, PokemonTable.attribute)\\
        .filter(PokemonTable.generation.in_(item.generations), PokemonTable.name.like(f"%{item.searchText}%"))

    pokemons = query.all()
    return pokemons

저는 해당 API에서 포켓몬의 번호, 도트 이미지, 도트 다른 색상 이미지, 속성 정보가 필요하였습니다.

이름은 데이터 오류 발생 시 체크를 위해 추가로 받기로 하였습니다.

원하는 데이터 5개를 받기 위하여 query()안에 5개를 입력하였습니다.

filter는 조건문으로 세대(generation) 정보를 리스트 형식으로 받아서 IN절로 필요한 데이터를 필터링합니다.

그리고 검색어인 searchText의 문구가 들어간 포켓몬을 Like절을 이용하여 필터링합니다.

all()을 통해 해당하는 포켓몬들을 데이터베이스에서 가져온 후 return을 하게 됩니다.

2) 서버 연결

기본적인 API 사용은 이번 글에서는 생략합니다.

실제 폰이나 가상 디바이스에서는 http://localhost:8000을 Base Url로 사용하면 아무것도 작동을 하지 않습니다.

가상 디바이스 환경에서는 http://10.0.2.2:8000/ 이 주소를 이용하면 서버에 접근할 수 있습니다.

(물론 서버 작동 중이어야 합니다.)

실제 폰에서 확인해 보기 위해서는 ngrok을 이용해야 합니다.

Mac =>
brew install ngrok/ngrok/ngrok

Window =>
choco install ngrok

사용 환경에 따라 위의 명령어를 터미널에 입력해 주면 됩니다.

기본적으로 8000 포트를 이용하므로 터미널에서

ngrok http 8000

위의 명령어를 입력해주면

여기서 나온 Frowarding 주소를 Base Url로 설정하고 진행하면 됩니다.

다만 이건 실행할 때마다 주소가 변경되고 일정 시간이 지나면 연결이 안 되므로 주기적으로 Base Url의 값을 변경해 주어야 합니다.

이건 어디까지나 해당 주소의 값을 외부에 공유하는 개념이므로 서버 작동하는 터미널이 따로 있어야 합니다.

3) UI

이 화면에서는 포켓몬의 리스트 화면과 메뉴를 보여주는 화면이 같이 존재합니다.

메뉴를 그리는 기능은 ModalDrawer를 이용하여 구현하였습니다.

ModalDrawer는 메뉴가 닫혔는지 열렸는지 상태 값을 저장한 drawerState를 받고

drawerContent는 메뉴에 표시할 내용을, content는 메인 화면을 받습니다.

3-1) 리스트 메인 화면

@Composable
fun PokemonListContent(
    routeAction: RouteAction,
    viewModel: ListViewModel,
    scope: CoroutineScope,
    drawerState: DrawerState
) {
    val focusManager = LocalFocusManager.current
    val loadingState = remember { mutableStateOf(true) }
    val state = viewModel.eventStateFlow.collectAsState()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .nonRippleClickable { focusManager.clearFocus() }
    ) {
        /** 상단 타이틀 **/
        PokemonListHeader(routeAction, viewModel, scope, drawerState)

        /** 검색창, 포켓몬 리스트 **/
        PokemonListBody(routeAction, viewModel)

        when (state.value) {
            ListViewModel.Event.Init -> {
                loadingState.value = true
            }
            ListViewModel.Event.Complete -> {
                loadingState.value = false
            }
        }

        LoadingDialog(loadingState)
    }
}

리스트 메인에서는 Header와 Body로 구분을 두었습니다.

private val _stateFlow = MutableStateFlow<Event>(Event.Init)
val eventStateFlow : StateFlow<Event> = _stateFlow

sealed class Event {
    object Init: Event()
    object Complete: Event()
}

ViewModel에서는 StateFlow를 이용하여 서버 조회 시 Init과 Complete를 변경하여 전달하고 UI 쪽에서는 loadingState의 값을 변경함으로써 LoadingDailog를 표시 여부를 결정하게 됩니다.

val focusManager = LocalFocusManager.current
...

Column(
    modifier = Modifier
        .fillMaxSize()
        .nonRippleClickable { focusManager.clearFocus() }
)
...

focusManager는 focus를 제거하여 검색 창에서 검색 후 다른 영역 클릭 시 키보드를 제거하기 위하여 사용하였습니다.

@Composable
fun Modifier.nonRippleClickable(
    onClick: () -> Unit
) = clickable(
        indication = null,
        interactionSource = remember { MutableInteractionSource() }
    ) {
        onClick()
    }

위에서 사용한 nonRippleClickable은 확장 함수로 위와 같이 정의하였습니다.

Modifier에서 clickable을 사용하고 클릭하면 받은 대상이 버튼 눌렀을 때 나오는 효과가 생기게 됩니다.

근데 배경 클릭 시에 그런 효과가 들어가면 이상하므로 효과를 제거하였습니다.

@Composable
fun PokemonListHeader(
    routeAction: RouteAction,
    viewModel: ListViewModel,
    scope: CoroutineScope,
    drawerState: DrawerState,
) {
    Row(
        modifier = Modifier.fillMaxWidth()
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_prev),
            contentDescription = "prev",
            colorFilter = ColorFilter.tint(getBlack()),
            modifier = Modifier
                .padding(all = 17.dp)
                .nonRippleClickable { routeAction.popupBackStack() }
        )

        Spacer(modifier = Modifier.weight(1f))

        Image(
            painter = painterResource(id = if (viewModel.imageState.value) R.drawable.ic_shiny else R.drawable.ic_none_shiny),
            contentDescription = "shiny",
            modifier = Modifier
                .padding(top = 17.dp, end = 10.dp)
                .size(24.dp)
                .nonRippleClickable {
                    viewModel.event(ListEvent.ImageStateChange)
                }
        )
        Image(
            painter = painterResource(id = R.drawable.ic_menu),
            contentDescription = "menu",
            colorFilter = ColorFilter.tint(getBlack()),
            modifier = Modifier
                .padding(top = 17.dp, end = 24.dp)
                .nonRippleClickable {
                    scope.launch {
                        viewModel.event(ListEvent.MenuOpen)
                        drawerState.open()
                    }
                }
        )
    }
}

헤더 영역에서는 뒤로 가기 버튼, 포켓몬 색상 변경 버튼, 메뉴 버튼이 있습니다.

다른 건 특별한 건 없고 메뉴 버튼을 눌렀을 때 DrawerState.open() 함수가 Corutine으로 정의되어 있으므로 CoroutineScope를 이용하여야 합니다.

@Composable
fun PokemonListBody(
    routeAction: RouteAction,
    viewModel: ListViewModel
) {
    /** 검색창 **/
    SearchTextField(viewModel)

    /** 포켓몬 리스트 **/
    LazyColumn(
        contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
        modifier = Modifier
            .fillMaxWidth()
    ) {
        gridItems(
            data = viewModel.pokemonList,
            columnCount = 4,
            horizontalArrangement = Arrangement.spacedBy(10.dp),
        ) { item, _ ->
            PokemonListItem(
                item = item,
                isShiny = viewModel.imageState.value,
                clickListener = {
                    routeAction.navToDetail(it)
                }
            )
        }
    } // 포켓몬 리스트
}

바디 영역에서는 검색창과 포켓몬 리스트를 표시합니다.

포켓몬 리스트는 메인 화면과 동일하게 LazyColumn안에서 그리드 형식으로 이미지를 표시합니다.

@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun SearchTextField(viewModel: ListViewModel) {
    val keyboardController = LocalSoftwareKeyboardController.current
    OutlinedTextField(
				...
        keyboardOptions = KeyboardOptions(
            imeAction = ImeAction.Search
        ),
        keyboardActions = KeyboardActions(
            onSearch = {
                viewModel.event(ListEvent.Search)
                keyboardController?.hide()
            }
        ),
        modifier = Modifier
            .padding(horizontal = 24.dp)
            .fillMaxWidth()
    )
}

검색창은 대부분의 옵션은 UI를 설정하는 부분입니다.

KeyboardOptions를 이용해 ImeAction.Search 설정으로 키보드에 검색 아이콘이 나오도록 설정을 합니다.

KeyboardActions를 이용해 검색을 하였을 때 KeyboardController를 사용해 키보드를 제거해 줍니다.

val imageLoader = ImageLoader.Builder(context)
    .components {
        if (Build.VERSION.SDK_INT >= 28) {
            add(ImageDecoderDecoder.Factory())
        } else {
            add(GifDecoder.Factory())
        }
    }
    .build()

포켓몬 리스트에서 아이템을 보여주는 UI에서 이미지 라이브러리 중 Coil을 사용해 보았습니다.

Coil의 경우 ImageLoader를 이용하여 각종 설정이 가능한데 이번 프로젝트에서는 gif 이미지가 포함되어 있어서 위와 같이 설정을 하였습니다.

AsyncImage(
    model = if (isShiny) item.dotShinyImage else item.dotImage,
    contentDescription = null,
    error = painterResource(id = R.drawable.img_monsterbal),
    placeholder = painterResource(id = R.drawable.img_monsterbal),
    imageLoader = imageLoader,
    modifier = Modifier.size(56.dp)
)

Compose에서 Coil 라이브러리로 이미지를 표시하기 위해서는 AsyncImage를 사용하면 됩니다.

model 영역에는 이미지의 주소를 넣으면 됩니다.

필요에 따라 error과 placeholder를 추가해 줍니다.

저는 gif 이미지가 있어서 위에서 만든 ImageLoader를 넣어주었습니다.

3-2) 메뉴 화면

@Composable
fun MenuBody(
    scope: CoroutineScope,
    drawerState: DrawerState,
    viewModel: ListViewModel
) {

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(getWhite())
    ) {

        /** 타이틀 **/
				....

        LazyColumn(
            modifier = Modifier
                .weight(1f)
                .fillMaxWidth()
        ) {

            /** 각 세대 선택 **/
            item {
                val list = viewModel.generateList
                GenerateButtons(list, viewModel)
            }

            /** 각 타입 선택 **/
            gridItems(
                data = viewModel.typeList,
                columnCount = 5,
                horizontalArrangement = Arrangement.spacedBy(10.dp),
                modifier = Modifier.padding(horizontal = 16.dp)
            ) { data, index ->
                Image(
                    painter = painterResource(id = data.imageRes),
                    contentDescription = "type",
                    alpha = if (data.isSelect) 1f else 0.3f,
                    modifier = Modifier.nonRippleClickable {
                        viewModel.event(ListEvent.TypeCondition(index))
                    }
                )
            }
        }
        /** 적용 버튼 **/
				...
    }
}

코드 양이 많아서 중간중간 잘랐습니다.

아직 여기 부분은 코드 분리를 안 시켰는데 나중에 분리를 해주어야겠습니다.

LazyColumn안에서 gridItems을 이용하여 타입과, 세대 선택을 구현하려고 하였으나

내부 적으로 인덱스 값을 이용하게 되는데 두 개의 리스트로 그리게 되면 키값이 겹치는 에러 사항이 발생하였습니다.

그래서 개수가 적은 세대 버튼들의 경우 단순하게 하드코딩으로 구현하게 되었습니다.

이것과 관련해서 다른 방법이 있는지 고민을 해봐야겠습니다.

 

상세화면은 글이 길어져서 다음 편에서 이어서 작성하겠습니다.

728x90
댓글