티스토리 뷰

728x90

2022.08.21 - [안드로이드/코드] - 포켓몬 도감 만들기(2) : Fast Api, Compose, 홈 화면, 리스트 화면

 

포켓몬 도감 만들기(2) : Fast Api, Compose, 홈 화면, 리스트 화면

2022.08.21 - [안드로이드/코드] - 포켓몬 도감 만들기(1) : Fast Api 포켓몬 도감 만들기(1) : Fast Api Fast Api 설치 pip install fastapi 터미널에 위의 명령어를 입력하면 설치가 됩니다. 추가로 Python이 없..

alanboyce.tistory.com

이전 편에 이어서 상세 화면에 대해 작성하겠습니다.


상세 화면

1) UI

@Composable
fun DetailScreen(
    routeAction: RouteAction,
    viewModel: DetailViewModel = hiltViewModel(),
) {

    val info = viewModel.pokemonInfo.value
    val isShiny = viewModel.isShiny.value
    val stateCollector = viewModel.eventFlow.collectAsState()
    val isLoading = remember { mutableStateOf(false) }
    val isError = remember { mutableStateOf(false) }
    val typeList = info.attribute.split(",").toMutableList()

    ConstraintLayout(
        modifier = Modifier
            .fillMaxSize()
            .background(
                brush = Brush.horizontalGradient(getTypeColorList(typeList))
            )
    ) {
        val (header, body, footer) = createRefs()
        /** 타이틀 **/
        DetailHeader(
            routeAction = routeAction,
            info = info,
            isShiny = isShiny,
            modifier = Modifier
                .fillMaxWidth()
                .height(58.dp)
                .constrainAs(header) {
                    top.linkTo(parent.top)
                    start.linkTo(parent.start)
                    end.linkTo(parent.end)
                }
        ) // 타이틀

        /** 포켓몬 정보 표시 **/
        DetailFooter(
            info = info,
            viewModel = viewModel,
            isShiny = isShiny,
            modifier = Modifier.constrainAs(footer) {
                top.linkTo(body.bottom, (-27).dp)
                start.linkTo(parent.start)
                end.linkTo(parent.end)
                bottom.linkTo(parent.bottom)
                width = Dimension.fillToConstraints
                height = Dimension.fillToConstraints
            }
        ) // DetailFooter

        /** 포켓몬 이름, 속성, 이미지 및 이로치 변경 버튼 표시 **/
        DetailBody(
            info = info,
            viewModel = viewModel,
            isShiny = isShiny,
            modifier = Modifier.constrainAs(body) {
                top.linkTo(header.bottom)
                start.linkTo(parent.start)
                end.linkTo(parent.end)
            }
        ) // 포켓몬 정보 표시
        
        if (isLoading.value) {
            LoadingDialog(isLoading = isLoading)
        }

        if (isError.value) {
            ConfirmDialog(
                message = stringResource(id = R.string.load_error),
                isShow = isError
            ) {
                isError.value = false
                routeAction.popupBackStack()
            }
        }

    } // ConstraintLayout
    
    when(stateCollector.value) {
        DetailViewModel.Event.Init -> {
            isLoading.value = true
        }
        DetailViewModel.Event.Success -> {
            isLoading.value = false
        }
        DetailViewModel.Event.Failure -> {
            isLoading.value = false
            isError.value = true
        }
    }
}

상세화면은 Header, Body, Footer로 나누어 보았습니다.

포켓몬 정보를 Footer로 사용하기에는 애매한 감이 있긴 한데 상, 중, 하로 생각하고 나누었습니다.

이번에는 Column으로 하기에는 겹치는 영역이 존재하기 때문에 Constraint를 사용하였습니다.

XML방식에서는 각 View의 Id를 이용하여서 제약조건을 넣었는데 Compose는 기본적으로 Id를 사용하지 않기 때문에 별도의 변수를 사용해야 하는 점이 약간은 불편하긴 하네요.

그 외는 명칭이 변한 거 말고는 큰 차이를 못 느꼈습니다.

Header와 Body는 위의 내용과 별 차이가 없으므로 생략합니다.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun DetailFooter(
    info: PokemonItem,
    viewModel: DetailViewModel,
    isShiny: Boolean,
    modifier: Modifier = Modifier
) {
    val tabData = listOf(
        TabItem.Description(
            info = info,
            tabName = stringResource(id = R.string.description)
        ),
        TabItem.Status(
            status = info.status,
            tabName = stringResource(id = R.string.status)
        ),
        TabItem.TypeCompatibility(
            list = viewModel.typeCompatibility,
            tabName = stringResource(id = R.string.type_compatibility)
        ),
        TabItem.EvolutionContainer(
            list = info.evolutionList,
            isShiny = isShiny,
            tabName = stringResource(id = R.string.evolution)
        )
    )
    val pagerState = rememberPagerState(initialPage = 0)
    val tabIndex = pagerState.currentPage

    Card(
        shape = RoundedCornerShape(topStart = 40.dp, topEnd = 40.dp),
        colors = CardDefaults.cardColors(
            containerColor = getWhite()
        ),
        modifier = modifier
    ) {
        /** 탭 **/
        CustomScrollableTabRow(
            tabs = tabData.map { it.name },
            selectedTabIndex = tabIndex,
            pagerState = pagerState
        )

        /** 뷰페이저 **/
        HorizontalPager(
            modifier = Modifier.fillMaxSize(),
            state = pagerState,
            count = tabData.size
        ) { index ->
            tabData[index].screenToLoad()
        }
    }
}

포켓몬 정보 영역에서는 Tab과 ViewPager를 이용하고 있습니다.

ViewPager는 가로로 동작하기 위해 HorizontalPager를 이용하였습니다.

필요한 것은 HorizontalPager에 들어갈 Contents의 사이즈와 PagerState입니다.

rememberPagerState를 통해 PagerState를 만들 수 있고 초깃값을 줄 수도 있습니다.

sealed class TabItem(
    val name: String,
    val screenToLoad: @Composable () -> Unit
) {
    data class Description(
        val info: PokemonItem,
        val tabName: String
    ) : TabItem(
        name = tabName,
        screenToLoad = {
            DescriptionContainer(
                info = info,
                modifier = Modifier.fillMaxSize()
            )
        }
    )
		...
}

안에 들어갈 Contents는 위와 같이 sealed class로 정의해 주었습니다.

name은 Tab의 텍스트로 사용하고 screenToLoad는 해당 포지션이 왔을 때 UI를 그리기 위해 사용됩니다.

TabItem들을 List로 담아서 PagerState가 변경될 때 current page에 맞는 screenToLoad가 실행되도록 설정합니다.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun CustomScrollableTabRow(
    tabs: List<String>,
    selectedTabIndex: Int,
    pagerState: PagerState
) {
    val coroutineScope = rememberCoroutineScope()

    androidx.compose.material.TabRow(
        selectedTabIndex = selectedTabIndex,
        backgroundColor = getWhite(),
        indicator = { tabPositions ->
            TabRowDefaults.Indicator(
                Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)
            )
        },
        divider = {
            Divider(color = getGray(), modifier = Modifier.height(1.dp))
        }
    ) {
        tabs.forEachIndexed { index, tabItem ->
            Tab(
                selected = selectedTabIndex == index,
                selectedContentColor = MainColor,
                unselectedContentColor = getGray(),
                onClick = {
                    coroutineScope.launch {
                        pagerState.animateScrollToPage(index)
                    }
                }
            ) {
                Text(
                    text = tabItem,
                    style = Typography.bodyLarge,
                    modifier = Modifier.padding(top = 40.dp, bottom = 8.dp)
                )
            }
        }
    }
}

TabRow의 indicator의 속성으로 Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)을 설정해 주면 ViewPager와 Tab의 포지션 변경 시 indicator가 자연스럽게 같이 이동하게 됩니다.

만약 이 설정을 해주지 않으면 ViewPager와 Tab이 변경되었을 때 UI 반영이 먼저 완료된 후 indicator가 따로 움직이게 됩니다.

Tab의 Item을 클릭하면 선택한 아이템의 Index값으로 ViewPager를 이동시킵니다.

그러면 ViewPager의 CurrentPage의 값이 변경되고 그 변경된 값이 selectedTabIndex로 넘어오게 됩니다. 그 다음 그 값이 현재 Index의 값과 같은지 체크하여 선택 여부를 보여주게 됩니다.

2) API

# 특정 포켓몬 이미지 정보 조회 (인덱스)
@app.get("/pokemon/number/image/index/{index}")
def read_pokemon_dot_image(index: int):
    pokemon = session.query(PokemonTable.number, PokemonTable.name, PokemonTable.dotImage, PokemonTable.dotShinyImage, PokemonTable.attribute)\\
				.filter(PokemonTable.index == index).first()

    return pokemon

PokemonTable의 Index 값을 이용하여 포켓몬의 정보를 조회하는 API입니다.

filter에서 테이블의 Index와 전달받은 Index를 비교하여 필터링을 한 후 first로 첫 번째 데이터만 가져와서 return합니다.

만약 없는 Index를 입력하게 되면 null을 반환합니다.

{
  "number": "0001",
  "name": "이상해씨",
  "dotImage": "<https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/1.gif>",
  "dotShinyImage": "<https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/1.gif>",
  "attribute": "풀,독"
}
from sqlalchemy.orm import aliased

# 특정 포켓몬 정보 조회 (번호)
@app.get("/pokemon/number/{number}")
def read_pokemon(number: str):
    pokemon = session.query(PokemonTable).filter(PokemonTable.number == number).first()
    before = read_pokemon_dot_image(pokemon.index -1)
    after = read_pokemon_dot_image(pokemon.index + 1)
    pokemon1 = aliased(PokemonTable)
    pokemon2 = aliased(PokemonTable)
    evolution = session.query(pokemon1.dotImage.label('beforeDot'), pokemon1.dotShinyImage.label('beforeShinyDot'), pokemon2.dotImage.label('afterDot'), pokemon2.dotShinyImage.label('afterShinyDot'), EvolutionTypeTable.image.label('evolutionImage'), EvolutionTable.evolutionConditions)\\
        .filter(EvolutionTable.numbers.like(f"%{number}%"), EvolutionTable.beforeNum == pokemon1.number, EvolutionTable.afterNum == pokemon2.number, EvolutionTypeTable.name == EvolutionTable.evolutionType).all()

    return {"info": pokemon, "before": before, "after": after, "evolution": evolution}

포켓몬의 상세 정보를 조회하는 API입니다.

before와 after는 이전 포켓몬과 다음 포켓몬의 이미지를 상세화면의 상단에 표시하기 위한 정보입니다.

위에서 선언한 함수를 이용하여 종보를 가져옵니다.

pokemon은 선택한 포켓몬의 상세 정보를 가져옵니다. 조회 방식은 위와 동일합니다.

evolution 진화 정보가 약간 복잡한데 EvolutionTable은 위와 같이 데이터가 존재합니다.

(쿼리는 제가 아는 방법으로 짠 거라 더 효율 좋은 쿼리가 있을 수 있습니다.)

우선 선택한 포켓몬의 number가 EvolutionTable의 numbers에 포함이 되는지를 여부를 체크하기 위해

Like를 통해 필터링합니다.

그다음 포켓몬의 이미지 정보가 필요한데 그 정보는 PokemonTable에 있습니다.

그런데 진화 전과 진화 후 2개의 이미지가 각각 필요하므로 하나의 테이블을 참조할 수는 없습니다.

그래서 aliased를 통해 별칭을 만들어 두 개를 참조합니다.

각각의 테이블에서 이미지 정보를 가져와서 Column에 추가합니다.

Column의 별칭은 pokemon1.dotImage.label('beforeDot') 이런 식으로 지정해 주면 됩니다.

진화 타입의 이미지 역시 위와 동일한 방법으로 EvolutionTypeTable에가 가져옵니다.

마지막으로 해당 정보들을 json 형식으로 담아서 return을 해주면 완료가 됩니다.

{
  "info": {
    "number": "0002",
    "status": "60,62,63,80,80,60",
    "index": 2,
    "characteristic": "심록,엽록소",
    "dotImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/2.gif",
    "dotShinyImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/2.gif",
    "shinyImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/shiny/2.png",
    "generation": 1,
    "name": "이상해풀",
    "classification": "씨앗포켓몬",
    "attribute": "풀,독",
    "image": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/2.png",
    "description": "등의 봉오리가 부풀어 오르면 달콤한 냄새가 감돌기 시작한다. 큰 꽃이 필 조짐이다."
  },
  "before": {
    "number": "0001",
    "name": "이상해씨",
    "dotImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/1.gif",
    "dotShinyImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/1.gif",
    "attribute": "풀,독"
  },
  "after": {
    "number": "0003",
    "name": "이상해꽃",
    "dotImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/3.gif",
    "dotShinyImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/3.gif",
    "attribute": "풀,독"
  },
  "evolution": [
    {
      "beforeDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/1.gif",
      "beforeShinyDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/1.gif",
      "afterDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/2.gif",
      "afterShinyDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/2.gif",
      "evolutionImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/rare-candy.png",
      "evolutionConditions": "Lv.16"
    },
    {
      "beforeDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/2.gif",
      "beforeShinyDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/2.gif",
      "afterDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/3.gif",
      "afterShinyDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/3.gif",
      "evolutionImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/rare-candy.png",
      "evolutionConditions": "Lv.32"
    },
    {
      "beforeDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/3.gif",
      "beforeShinyDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/3.gif",
      "afterDot": "https://static.wikia.nocookie.net/pokemon/images/e/e2/%EB%8F%84%ED%8A%B8_6XY_003%EB%A9%94%EA%B0%80.gif/revision/latest?cb=20140317133431&path-prefix=ko",
      "afterShinyDot": "https://static.wikia.nocookie.net/pokemon/images/4/40/%EB%8F%84%ED%8A%B8_6XY_003%EB%A9%94%EA%B0%80_%EC%83%89%EB%8B%A4%EB%A5%B8.gif/revision/latest?cb=20140728081111&path-prefix=ko",
      "evolutionImage": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/key-stone.png",
      "evolutionConditions": "메가진화"
    },
    {
      "beforeDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/3.gif",
      "beforeShinyDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated/shiny/3.gif",
      "afterDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/10195.png",
      "afterShinyDot": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/shiny/10195.png",
      "evolutionImage": "https://w.namu.la/s/5b739c4aa36d2fd6bf73e0342bce10270f30d050a9e187424a2d0a38242a95e2ddcc81d5da07596562cc8e78bdd4cad7f45a3da47d3dcaea90af9fb80f6fde14128d7b1eefaf182f7acdd6a1836f24f41f67f2fdaeabb5960437a712ddf84bfc",
      "evolutionConditions": "거다이맥스"
    }
  ]
}

 

이 화면들 외에도 포켓몬 등록 화면과 진화 등록 화면이 존재하지만

단순히 TextFiled 반복이고 서버역시 나온 페이지와 크게 다를게 없어서 생략하였습니다.

 

728x90
댓글