Compose 무작정 맛보기 [5. 학점 통계 및 크롭 화면]

ricky_0_k·2022년 4월 11일
0

Compose 맛보기

목록 보기
6/7

서론

이 화면에서는 시행착오보다는 Compose 내에서 기존의 뷰를 사용하는 방법을 고민했던 시간이 더 많았다.

그리고 반드시 해야하는 작업으로 생각했던 건 Crop 기능 개선!
Crop 라이브러리 코드를 그대로 때려박았기 때문에 반드시 수정이 필요한 코드였다.

작업 시작 날짜를 보니 1월 15일이다. (TMI 로 코로나 걸리기 7일전의 나였다)
이 당시의 나는 어떻게 작업을 했었을까?

화면에 대한 소개

저번에 화면에 대한 소개를 넣어보니 개인적으로 좋았다.
그래서 이번에도 추가한다 ㅇㅅㅇ

1. 학점 통계 화면 (기본)

갑자기 범블비로 업뎃하면서 안스 내에 내장이 되어있다. (이거 원래대로 밖에 빼는 방법 아시는 분 댓글좀...)
전체, 각 학기 탭이 있고, 각 카테고리 별로 아래의 3단 그래프들과, 꺾은선 그래프들을 볼 수 있다.
그리고 우측 상단의 버튼을 통해 화면 캡처를 할 수 있다.

BottomSheet 가 전체를 아우르고, 그 안에 ToolBar, TabLayout, ContentView(Fragment) 등이 들어있다.

2. 크롭 화면 및 선택 후 화면

위와 같이 크롭 화면을 지정하여 카카오톡, 페이스북으로 공유할 수 있는 프로세스이다.
크롭하기를 선택하면 bottom sheet 가 나오고 카카오톡 공유하기, 페북 게시글 쓰기 를 할 수 있다.

페북 게시글 쓰기 취소선도 설명이 필요할 듯 한데.... 최신 버전 앱에서는 아직 불가능하다.
페이스북에서 queries 권한을 설정하라고 했는데, 하라는 대로 했는데도 안 되어서...
일단 대응만 해놓고 개선 작업으로 두었다..

학점 통계 화면

앞서 이야기했다시피 난 Compose 내에서 기존 xml 뷰를 쓰고 싶다고 했었다.
그리고 Compose ViewPager 와 xml 의 ViewPager 를 한번 비교해보고 싶었다.
그래서 전체를 아우르는 화면은 Composable View 로 구현하였다

1. 학점 통계화면 BottomSheet View

이번에도 전체 코드를 올리면서 키포인트들을 설명해보려 한다.
코드를 보면 알겠지만 BottomSheetScaffold 가 전체 View 를 감싸는 구조이다.
그렇다보니 설명을 어떻게할지 고민했었고, 전체 View 보다는 BottomSheet View 를 먼저 설명하게 되었다.

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun StatisticView(vm: StatisticViewModel? = null) {
	// 1
    val context = LocalContext.current
    val activity = context as StatisticActivity
    
    // 2
    val coroutineScope = rememberCoroutineScope()

	// 3
    val bottomSheetState by remember {
        mutableStateOf(BottomSheetState(BottomSheetValue.Collapsed))
    }
    var isVisible by remember { mutableStateOf(false) }
    
    // 4
    val bottomSheetScaffoldState =
        rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState).apply {
            if (bottomSheetState.isCollapsed) coroutineScope.launch { isVisible = false }
        }

	// 5
    var statisticViewPager: ViewPager2? = null
    fun getStatisticViewPager(context: Context): ViewPager2 {
        statisticViewPager = statisticViewPager ?: ViewPager2(context).apply {
            layoutParams = ViewGroup.MarginLayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
            )
            adapter = StatisticPagerAdapter(activity)
            offscreenPageLimit = 1
        }
        return statisticViewPager!!
    }

	// 6
    val result = remember { mutableStateOf<CropImageView.CropResult?>(null) }
    val cropResultLauncher = rememberLauncherForActivityResult(CropImageContract()) {
        Timber.i("$result")

        coroutineScope.launch {
            result.value = it
            isVisible = if (result.value?.isSuccessful == true) {
                bottomSheetState.expand()
                true
            } else {
                PhotoUtil.INSTANCE.clear()
                // throw result.value?.error ?: Throwable()
                result.value?.error?.printStackTrace()
                false
            }
        }
    }

	// 7
    /** 권한 관련 ActivityResult 처리 */
    val permissionLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) {
        if (it.values.filter { isGranted -> isGranted }.size == 2) {
            val bitmap = activity.window.decorView.rootView.getBitmapFromView()
            val originalUri = PhotoUtil.INSTANCE.saveBitmapUri(context.applicationContext, bitmap)
            cropResultLauncher.launch(
                CropImageContractOptions(
                    originalUri, CropImageOptions().apply {
                        guidelines = CropImageView.Guidelines.ON
                        customOutputUri = originalUri
                    }
                )
            )
        } else vm?.sendToast(R.string.toast_crop_permission_error)
    }

	// 8
    BottomSheetScaffold(
        sheetElevation = 0.dp,
        sheetBackgroundColor = colorResource(id = R.color.transparent),
        backgroundColor = colorResource(id = R.color.transparent),
        scaffoldState = bottomSheetScaffoldState,
        sheetContent = {
        	// 9
            AnimatedVisibility(
                visible = isVisible,
                enter = fadeIn(animationSpec = tween(1000)),
                exit = fadeOut(animationSpec = tween(0)),
            ) {
                // 10
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        .background(color = colorResource(id = R.color.transparent_gray))
                        .clickable(
                            interactionSource = remember { MutableInteractionSource() },
                            indication = null,
                        ) {
                            coroutineScope.launch {
                                bottomSheetState.collapse()
                                isVisible = false
                            }
                        },
                ) {
                    Column(
                        Modifier
                            .fillMaxWidth()
                            .background(color = colorResource(id = R.color.white))
                            .align(Alignment.BottomCenter),
                        verticalArrangement = Arrangement.Center,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text(
                            modifier = Modifier
                                .padding(12.dp)
                                .clickable(
                                    enabled = true,
                                    interactionSource = remember { MutableInteractionSource() },
                                    indication = rememberRipple(bounded = true),
                                    onClick = {
                                        vm?.startSnsAction(ShareSNSAction.KAKAO)
                                        // 11
                                        coroutineScope.launch {
                                            bottomSheetState.collapse()
                                            isVisible = false
                                        }
                                    }
                                ),
                            text = stringResource(id = R.string.tv_statistic_share_kakao)
                        )
                        Text(
                            modifier = Modifier
                                .padding(12.dp)
                                .clickable(
                                    enabled = true,
                                    interactionSource = remember { MutableInteractionSource() },
                                    indication = rememberRipple(bounded = true),
                                    onClick = {
                                        vm?.startSnsAction(ShareSNSAction.FACEBOOK)
                                        // 11
                                        coroutineScope.launch {
                                            bottomSheetState.collapse()
                                            isVisible = false
                                        }
                                    }
                                ),
                            text = stringResource(id = R.string.tv_statistic_share_facebook)
                        )
                    }
                }
            }
        },
        sheetPeekHeight = 0.dp,
        modifier = Modifier.fillMaxSize(),
    ) { ... }
}
  1. 리소스 가져오는 데 context 를 안쓰다보니 이 값들을 가져오는 방법에 대해 전혀 신경을 안쓰고 있었다.
    하지만 xml View 를 구현하려다 보니 필요해져 이런식으로 가져올 수 있음을 알게 되었다.
    여기서 응용하여 application 차원의 context 를 가져오려면 어떻게 해야할까?
    LocalContext.current.applicationContext 를 통해 가져올 수 있다.

  2. 의외로 코루틴 스코프를 선언하는 게 매우 쉬웠다.
    처음에 나는 viewModelScope 를 사용했었지만 동작 도중 (ex. 크롭 화면 다녀온 후, 화면이 다시 백그라운드에서 보여질 때 등) 먹통이 될 때가 있었다. 그러다가 이 방법을 알고 이대로 처리하게 되었다.

    정의부를 보았을 때 무슨 Dispatcher 를 사용하는지 알기 힘들어 전문가의 글들을 좀 검색해보았다.
    참고해보니 이런 문구가 있었다.

    Composable 내부에서 코루틴을 수행할 경우 Composable 에 대한 Recomposition 이 일어날 때 정리되어야 하는 Coroutine 이 정리가 안된 상태로 계속해서 Coroutine 이 쌓일 수 있다. Recomposition 은 자주 일어나는 동작이므로 Recomposition 마다 Coroutine 을 생성하는 것은 위험하며 심할 경우 앱 crash 를 발생시킬 수도 있다.

    따라서 Composable 에서 Coroutine 을 생성한다면 Recomposition 이 일어날 때 취소 되어야 한다. (꼭 그렇지 않은 경우도 있지만 그래야 하는 경우가 대부분이다). Compose 는 이를 위해 Composable 의 Lifecycle 을 따르는 CoroutineScope을 반환하는 rememberCoroutineScope() 함수를 제공한다.

    이 내용을 보니 왜 이게 있는지는 알게 되었고, Compose 내에서는 무조건 rememberCoroutineScope() 를 사용해야겠다는 생각이 들었다.
    위 설명을 언급한 전문가의 글을 꼭 읽어보기를 개인적으론 추천한다.

  3. 기존 bottomSheet 에서 Expend, Collapsed 등을 state 형태로 관리하는듯 하다.
    recomposition 하더라도 상태가 유지되어야 하니 저게 맞는 듯 하다.

  4. bottomSheet (BottomSheetScaffold) 에서 사용하는 state 이다.
    apply { ... } 는 실험하면서 남은 내용이어서 추후 제거해야 할 내용이다.
    bottomSheetState 가 Collapsed 일 때 isVisible 를 false 로 해주는 건데, 저 코드가 없어도 동작엔 무리가 없다.

  5. 조금 동작 방식이 희안한데, composable 함수 내에 ViewPager 변수가 있고, 이 값을 내부 singleTone 형태로 받을 수 있는 형태이다.
    이로 인해 추후 언급할 학점 통계화면 Content View 에서 해당 함수를 인지하고 viewPager 를 가져올 수 있다.

    하지만 개인적으로 마음에 들지 않는 코드이다.
    추후 ViewPager 를 바로 선언 형태로 하고 내부에서도 참조 할 수 있는 방법이 있을지, 그러면서 ViewPager 위치 자체를 옮길지도 생각하고 있다.

  6. 뒷장의 Crop 화면 라이브러리와 연관이 되는 내용이기도 하다. 하지만 내용은 쉽다.
    크롭 화면에 이동하여 이에 상응하는 결과값 (CropImageView.CropResult) 을 받아오고
    성공, 실패 여부에 따라 동작을 다르게 해주는 것이다.
    성공했을 경우 띄워줄 이미지를 기반으로 bottomSheet 를 띄워주어 어디에 공유할 지 선택할 수 있다.
    참고로 CropImageContract 는 ActivityResultContract 를 상속받아 custom 하게 만든 내용이며 이는 뒷 내용에서 후술할 것이다.

  7. 위의 Crop 과 비슷하다. 크롭화면에서의 결과값 대신 권한 결과값을 가지고 처리를 진행한다.
    6번에서 대충 예상했겠지만, Compose 에서는 startActivityResult 대신 Contract 를 활용하여 처리한다. 실제 deprecated 되었기도 하고, 이걸 활용하는 게 맞는거긴 하다.
    여기에서는 현재 화면을 bitmap 으로 저장하고 크롭 화면으로 이동하는 역할을 수행한다.

  8. BottomSheet (BottomSheetScaffold) 이다. 큰 내용은 없고 세부 컬러들을 설정할 수 있다.
    개인적으론 여기서 좀 삽질이 있었다. 2중으로 색이 씌워진다거나 등등의 이슈가 있었지만 지금은 해결했다.

  9. bottomSheet 가 뜰 때 Animation 을 적용했다. 뒷 배경이 fadeIn 형태로 회색 배경이 되고, fadeout 형태로 원래 배경으로 돌아온다.

  10. 회색 배경에 하단에만 컨텐츠가 있기 때문에 Box 를 활용하여 구현했다.
    Dim 영역을 클릭할 경우 bottom sheet 가 닫힌다.

  11. bottom sheet 를 닫는 건 coroutinescope.launch 를 통해 진행해야 한다.

2. 학점 통계화면 View

이제 실제 보여지는 View 이다.
이것도 전체 이야기는 아니고, 탭 레이아웃과, ViewPager 에 대한 내용만 있다.


const val cd_btn_statistic_share = "btn_statistic_share"

fun StatisticView(vm: StatisticViewModel? = null) {
	
    ...
    
	BottomSheetScaffold(
		...
	) {
    	// 1
        Toolbar(
            navigationIcon = { vm?.let { BackButton(vm) } },
            titleRes = R.string.tv_statistic_toolbar_title,
            actions = {
                Image(
                    painterResource(R.drawable.btn_statistic_share),
                    contentDescription = cd_btn_statistic_share,
                    modifier = Modifier
                        .clickable(
                            enabled = true,
                            interactionSource = remember { MutableInteractionSource() },
                            indication = rememberRipple(bounded = true),
                            onClick = {
                                coroutineScope.launch {
                                    delay(300)
                                    
                                    // 2
                                    permissionLauncher.launch(
                                        arrayOf(
                                            Manifest.permission.READ_EXTERNAL_STORAGE,
                                            Manifest.permission.WRITE_EXTERNAL_STORAGE,
                                        )
                                    )
                                }
                            }
                        )
                        .padding(12.dp)
                )
            }
        )
        
        // 3
        AndroidView(factory = { context ->
        	// 4
            val tabLayout = TabLayout(context).apply {
                layoutParams = ViewGroup.MarginLayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
                )
                setBackgroundColor(R.color.white.getColor(context))
                elevation = 3.dp.value
                tabMode = TabLayout.MODE_SCROLLABLE
                setTabTextColors(
                    R.color.statisticTabColor.getColor(context),
                    R.color.statisticTabSelectedColor.getColor(context)
                )
            }

			// 5
            tabLayout.apply {
                val tabTitles = resources.getStringArray(R.array.tb_statistic_tab_titles)
                TabLayoutMediator(this, getStatisticViewPager(context)) { tab, position ->
                    tab.text = tabTitles[position]
                }.attach()
            }
        })

		// 6
        AndroidView(factory = { context -> getStatisticViewPager(context) })
    }
}
  1. 최상위에 보여주는 ToolBar 이다.
    navigationIcon (왼쪽 영역) 에는 백 버튼이 있고, actions (우측 영역) 에는 권한 체크 후 크롭 화면을 킬 수 있도록 하였다.
    권한 체크 후의 동작은 BottomSheetScaffold 설명의 권한 로직 내용 (7번) 을 확인하면 된다.

  2. 위에서 언급했던 권한 체크하는 요청 로직이다. 기존 방식과 별 다른 차이가 없다.

  3. Composable 내에서 기존의 View 를 사용하는 방식 중 Kotlin(Java) 파일 차원에서 View 를 만드는 방법이다. 기존 방식을 사용하는 경우에는 AndroidView 라는 Composable 을 사용한다.

  4. 위에서 말했던 AndroidView 를 사용하여 TabLayout 를 선언하였다.
    사실 말만 거창하지 별다른 차이가 없다. 인스턴스 선언에서부터 params 설정 및 기타 설정까지 모두 기존 방식과 같다.
    굳이 차이를 두자면 AndroidView 와 factory 의 context 를 활용하는 게 차이가 되겠다.

  5. TabLayoutMediator 를 사용하는 것 또한 이전과 차이가 없다.

  6. ViewPager 또한 AndroidView 를 통해 가져오며, 이는 위에서 이야기했다시피 추후 개선해야 할 내용이다.

3. 학점 통계화면 Content View(Fragment), PagerAdapter

위에서 언급했다시피 TabLayout 의 탭에 맞게 띄워주어야 하는 화면들은 Fragment 를 이용해 구현하였다.
그리고 ViewPager 를 통해 swipe 할 수 있도록 구현하였다.

1. 학점 통계화면 Content View (Fragment)

Fragment 에서는 Compose 를 사용하는 내용이 없어 따로 언급하지 않을 예정이다.
이 부분에 대해서는 코드 라인이 어느정도 줄었는지만 언급하려 한다.

그래.. 140 줄 정도 줄이고 코드를 깨끗하게 한 것에 만족한다.

1. PagerAdapter

PagerAdapter 에 대해서는 짤막하게 코드만 언급하고 넘어가려 한다.

class StatisticPagerAdapter(activity: StatisticActivity) : FragmentStateAdapter(activity) {
    private val tabTitles = activity.resources.getStringArray(R.array.tb_statistic_tab_titles)

    override fun getItemCount(): Int = tabTitles.size

    override fun createFragment(position: Int): Fragment {
        val fragment = StatisticFragment()
        fragment.arguments = Bundle().apply { putInt(Const.BUNDLE_SEMESTER_NUM, position + 1) }
        return fragment
    }
}

기존 구현과 큰 차이는 없다. 일반적으로 우리가 구현했던 adapter 로 생각하면 더 좋을 것이다.

4. 기타

위에서 언급했던 CropImageContract 에 대해서만 언급하려 한다.
CropImageContract 는 크롭 화면으로 넘어가서 결과를 받아오는 명세를 한 내용이다.

class CropImageContract :
    ActivityResultContract<CropImageContractOptions, CropImageView.CropResult>() {

    override fun createIntent(context: Context, input: CropImageContractOptions): Intent {
        input.cropImageOptions.validate()
        return Intent(context, CropActivity::class.java).apply {
            val bundle = Bundle().apply {
                putParcelable(CropImage.CROP_IMAGE_EXTRA_SOURCE, input.uri)
                putParcelable(CropImage.CROP_IMAGE_EXTRA_OPTIONS, input.cropImageOptions)
            }
            putExtra(Const.EXTRA_SHARE_URI, input.uri)
            putExtra(CropImage.CROP_IMAGE_EXTRA_BUNDLE, bundle)
        }
    }

    override fun parseResult(
        resultCode: Int,
        intent: Intent?
    ): CropImageView.CropResult {
        val result = intent?.getParcelableExtra<Parcelable>(CropImage.CROP_IMAGE_EXTRA_RESULT)
            as? CropImage.ActivityResult?

        return if (result == null || resultCode == Activity.RESULT_CANCELED)
            CropImage.CancelledResult
        else result
    }
}

CropResult 는 데이터 뭉치이다. bitmap 등의 정보들이 있다.
createIntent() 를 통해 화면을 이동하고, parseResult() 를 통해 받아온 결과를 분석한다고 이해하면 편할 것이다.

크롭 화면

사실 크롭화면도 Compose 를 쓰는 게 없다.
다만 리펙터링을 통해 코드량이 어느정도 줄었는지만 언급하려 한다.

기존에 라이브러리 코드를 다 넣어서 개조했던 걸 수정해서 그런지 최소 250 줄 가량이 줄었다.

결론

이번 작업을 통해 Compose 내에서 기존의 View 를 사용하려면 AndroidView 를 사용해야 한다는 것을 알 수 있었다.
그리고 구현하면서 coroutineScope 를 어떻게 써야할지, startActivityResult 대신 어떻게 써야할지를 알 수 있었다.

다음으로 내가 작업한 내용은 설정화면이다.
여기서는 아키텍처 부분도 같이 언급하면서 포스팅을 할 예정이다.

끄적임

포스팅을 쓰면서 드는 생각이 있다.

  1. 프로젝트 public 하게 만들기
    앞서 이야기했지만 Compose 이외의 영역은 언급하지 않고 넘어가다보니 일부 아쉬운 점이 있었다.
    개선한 코드에서도 내가 열심히 한 작업이고, 내가 놓친 것들이 있을 것 같다는 생각이 들어서 말이다...

    개선하면서 이 프로젝트를 public 하게 열 수 있는 방법도 생각해보려 한다.
    가능하면 사람들의 PR 도 받아보고 싶고, issue 도 받아보고 싶지만 가능할지는 모르겠다.
    일단은 보일 수 있게 만들어 놓으려 한다.

  2. 리펙터링 내용 따로 다뤄보기
    기존에는 설정화면이 마지막 포스팅이었다.
    그런데 1번 생각을 하면서 예정에 없었던 리펙터링 내용을 다뤄볼까 생각중이다.
    Room 마이그레이션, 로직 정리 등 제법 굵직한 내용들이 있었기 때문이다.
    내가 했던 작업들도 머리에 다시 정리할 겸, 설정화면 대신 리펙터링을 마지막 포스팅 내용으로 하고자 한다.

참고

profile
valuable 을 추구하려 노력하는 개발자

0개의 댓글