이 화면에서는 시행착오보다는 Compose 내에서 기존의 뷰를 사용하는 방법을 고민했던 시간이 더 많았다.
그리고 반드시 해야하는 작업으로 생각했던 건 Crop 기능 개선!
Crop 라이브러리 코드를 그대로 때려박았기 때문에 반드시 수정이 필요한 코드였다.
작업 시작 날짜를 보니 1월 15일이다. (TMI 로 코로나 걸리기 7일전의 나였다)
이 당시의 나는 어떻게 작업을 했었을까?
저번에 화면에 대한 소개를 넣어보니 개인적으로 좋았다.
그래서 이번에도 추가한다 ㅇㅅㅇ
갑자기 범블비로 업뎃하면서 안스 내에 내장이 되어있다. (이거 원래대로 밖에 빼는 방법 아시는 분 댓글좀...)
전체, 각 학기 탭이 있고, 각 카테고리 별로 아래의 3단 그래프들과, 꺾은선 그래프들을 볼 수 있다.
그리고 우측 상단의 버튼을 통해 화면 캡처를 할 수 있다.
BottomSheet 가 전체를 아우르고, 그 안에 ToolBar, TabLayout, ContentView(Fragment) 등이 들어있다.
위와 같이 크롭 화면을 지정하여 카카오톡, 페이스북으로 공유할 수 있는 프로세스이다.
크롭하기를 선택하면 bottom sheet 가 나오고 카카오톡 공유하기, 페북 게시글 쓰기 를 할 수 있다.
페북 게시글 쓰기 취소선도 설명이 필요할 듯 한데.... 최신 버전 앱에서는 아직 불가능하다.
페이스북에서 queries 권한을 설정하라고 했는데, 하라는 대로 했는데도 안 되어서...
일단 대응만 해놓고 개선 작업으로 두었다..
앞서 이야기했다시피 난 Compose 내에서 기존 xml 뷰를 쓰고 싶다고 했었다.
그리고 Compose ViewPager 와 xml 의 ViewPager 를 한번 비교해보고 싶었다.
그래서 전체를 아우르는 화면은 Composable 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(),
) { ... }
}
리소스 가져오는 데 context 를 안쓰다보니 이 값들을 가져오는 방법에 대해 전혀 신경을 안쓰고 있었다.
하지만 xml View 를 구현하려다 보니 필요해져 이런식으로 가져올 수 있음을 알게 되었다.
여기서 응용하여 application 차원의 context 를 가져오려면 어떻게 해야할까?
LocalContext.current.applicationContext
를 통해 가져올 수 있다.
의외로 코루틴 스코프를 선언하는 게 매우 쉬웠다.
처음에 나는 viewModelScope 를 사용했었지만 동작 도중 (ex. 크롭 화면 다녀온 후, 화면이 다시 백그라운드에서 보여질 때 등) 먹통이 될 때가 있었다. 그러다가 이 방법을 알고 이대로 처리하게 되었다.
정의부를 보았을 때 무슨 Dispatcher 를 사용하는지 알기 힘들어 전문가의 글들을 좀 검색해보았다.
참고해보니 이런 문구가 있었다.
Composable 내부에서 코루틴을 수행할 경우 Composable 에 대한 Recomposition 이 일어날 때 정리되어야 하는 Coroutine 이 정리가 안된 상태로 계속해서 Coroutine 이 쌓일 수 있다. Recomposition 은 자주 일어나는 동작이므로 Recomposition 마다 Coroutine 을 생성하는 것은 위험하며 심할 경우 앱 crash 를 발생시킬 수도 있다.
따라서
Composable 에서 Coroutine 을 생성한다면 Recomposition 이 일어날 때 취소 되어야 한다.
(꼭 그렇지 않은 경우도 있지만 그래야 하는 경우가 대부분이다). Compose 는 이를 위해 Composable 의 Lifecycle 을 따르는 CoroutineScope을 반환하는 rememberCoroutineScope() 함수를 제공한다.
이 내용을 보니 왜 이게 있는지는 알게 되었고, Compose 내에서는 무조건 rememberCoroutineScope() 를 사용해야겠다는 생각이 들었다.
위 설명을 언급한 전문가의 글을 꼭 읽어보기를 개인적으론 추천한다.
기존 bottomSheet 에서 Expend, Collapsed 등을 state 형태로 관리하는듯 하다.
recomposition 하더라도 상태가 유지되어야 하니 저게 맞는 듯 하다.
bottomSheet (BottomSheetScaffold) 에서 사용하는 state 이다.
apply { ... } 는 실험하면서 남은 내용이어서 추후 제거해야 할 내용이다.
bottomSheetState 가 Collapsed 일 때 isVisible 를 false 로 해주는 건데, 저 코드가 없어도 동작엔 무리가 없다.
조금 동작 방식이 희안한데, composable 함수 내에 ViewPager 변수가 있고, 이 값을 내부 singleTone 형태로 받을 수 있는 형태이다.
이로 인해 추후 언급할 학점 통계화면 Content View 에서 해당 함수를 인지하고 viewPager 를 가져올 수 있다.
하지만 개인적으로 마음에 들지 않는 코드이다.
추후 ViewPager 를 바로 선언 형태로 하고 내부에서도 참조 할 수 있는 방법이 있을지, 그러면서 ViewPager 위치 자체를 옮길지도 생각하고 있다.
뒷장의 Crop 화면 라이브러리와 연관이 되는 내용이기도 하다. 하지만 내용은 쉽다.
크롭 화면에 이동하여 이에 상응하는 결과값 (CropImageView.CropResult) 을 받아오고
성공, 실패 여부에 따라 동작을 다르게 해주는 것이다.
성공했을 경우 띄워줄 이미지를 기반으로 bottomSheet 를 띄워주어 어디에 공유할 지 선택할 수 있다.
참고로 CropImageContract
는 ActivityResultContract 를 상속받아 custom 하게 만든 내용이며 이는 뒷 내용에서 후술할 것이다.
위의 Crop 과 비슷하다. 크롭화면에서의 결과값 대신 권한 결과값을 가지고 처리를 진행한다.
6번에서 대충 예상했겠지만, Compose 에서는 startActivityResult 대신 Contract 를 활용하여 처리한다. 실제 deprecated 되었기도 하고, 이걸 활용하는 게 맞는거긴 하다.
여기에서는 현재 화면을 bitmap 으로 저장하고 크롭 화면으로 이동하는 역할을 수행한다.
BottomSheet (BottomSheetScaffold) 이다. 큰 내용은 없고 세부 컬러들을 설정할 수 있다.
개인적으론 여기서 좀 삽질이 있었다. 2중으로 색이 씌워진다거나 등등의 이슈가 있었지만 지금은 해결했다.
bottomSheet 가 뜰 때 Animation 을 적용했다. 뒷 배경이 fadeIn 형태로 회색 배경이 되고, fadeout 형태로 원래 배경으로 돌아온다.
회색 배경에 하단에만 컨텐츠가 있기 때문에 Box 를 활용하여 구현했다.
Dim 영역을 클릭할 경우 bottom sheet 가 닫힌다.
bottom sheet 를 닫는 건 coroutinescope.launch 를 통해 진행해야 한다.
이제 실제 보여지는 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) })
}
}
최상위에 보여주는 ToolBar 이다.
navigationIcon (왼쪽 영역) 에는 백 버튼이 있고, actions (우측 영역) 에는 권한 체크 후 크롭 화면을 킬 수 있도록 하였다.
권한 체크 후의 동작은 BottomSheetScaffold 설명의 권한 로직 내용 (7번) 을 확인하면 된다.
위에서 언급했던 권한 체크하는 요청 로직이다. 기존 방식과 별 다른 차이가 없다.
Composable 내에서 기존의 View 를 사용하는 방식 중 Kotlin(Java) 파일 차원에서 View 를 만드는 방법이다. 기존 방식을 사용하는 경우에는 AndroidView
라는 Composable 을 사용한다.
위에서 말했던 AndroidView 를 사용하여 TabLayout 를 선언하였다.
사실 말만 거창하지 별다른 차이가 없다. 인스턴스 선언에서부터 params 설정 및 기타 설정까지 모두 기존 방식과 같다.
굳이 차이를 두자면 AndroidView 와 factory 의 context 를 활용하는 게 차이가 되겠다.
TabLayoutMediator 를 사용하는 것 또한 이전과 차이가 없다.
ViewPager 또한 AndroidView
를 통해 가져오며, 이는 위에서 이야기했다시피 추후 개선해야 할 내용이다.
위에서 언급했다시피 TabLayout 의 탭에 맞게 띄워주어야 하는 화면들은 Fragment 를 이용해 구현하였다.
그리고 ViewPager 를 통해 swipe 할 수 있도록 구현하였다.
Fragment 에서는 Compose 를 사용하는 내용이 없어 따로 언급하지 않을 예정이다.
이 부분에 대해서는 코드 라인이 어느정도 줄었는지만 언급하려 한다.
그래.. 140 줄 정도 줄이고 코드를 깨끗하게 한 것에 만족한다.
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 로 생각하면 더 좋을 것이다.
위에서 언급했던 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 대신 어떻게 써야할지를 알 수 있었다.
다음으로 내가 작업한 내용은 설정화면
이다.
여기서는 아키텍처 부분도 같이 언급하면서 포스팅을 할 예정이다.
포스팅을 쓰면서 드는 생각이 있다.
프로젝트 public 하게 만들기
앞서 이야기했지만 Compose 이외의 영역은 언급하지 않고 넘어가다보니 일부 아쉬운 점이 있었다.
개선한 코드에서도 내가 열심히 한 작업이고, 내가 놓친 것들이 있을 것 같다는 생각이 들어서 말이다...
개선하면서 이 프로젝트를 public 하게 열 수 있는 방법도 생각해보려 한다.
가능하면 사람들의 PR 도 받아보고 싶고, issue 도 받아보고 싶지만 가능할지는 모르겠다.
일단은 보일 수 있게 만들어 놓으려 한다.
리펙터링 내용 따로 다뤄보기
기존에는 설정화면이 마지막 포스팅이었다.
그런데 1번 생각을 하면서 예정에 없었던 리펙터링 내용을 다뤄볼까 생각중이다.
Room 마이그레이션, 로직 정리 등 제법 굵직한 내용들이 있었기 때문이다.
내가 했던 작업들도 머리에 다시 정리할 겸, 설정화면
대신 리펙터링
을 마지막 포스팅 내용으로 하고자 한다.
참고