Compose 무작정 맛보기 [4. 학점 및 과목 입력 화면]

서론

지난번에 이야기했다시피 state 를 깊게 다뤄보고, focusing 처리 등 UI 의 다양한 것을 접했던 시기였다.
디버깅해보면서 어느 시점에서 state 를 처리해주는 게 좋을지 등을 고민했던 기억도 난다.
그런 만큼 작업량도 많았고 코드량도 어마무시해졌다.

이렇다 보니 무작정의 여파가 많이 남았던 코드이기도 하다.
지금 돌아보면 수정이 필요한 내용들도 많다.
포스팅 완료 후 리펙터링을 할 때 제일 많이 건드려야 할 것 같은 화면이다.

포스팅 내용은 최종 작업 코드를 언급하면서, 번호 형태로 정리하려 한다.
코드 량이 많다보니 최대한 분리해서 볼 수 있도록 쪼개서 작성하려 노력해보려 한다.

2주간에 내가 어떤 작업들을 했었는지 이야기해보려 한다.

화면에 대한 소개

다량의 코드를 접할 예정이다보니(?), 화면에 대한 소개도 필요할듯하여 스크린샷을 먼저 첨부했다.

이런식으로 구현되어 있으며 간단한 기능 명세를 하면 이렇다.

  1. 우측 상단에 창에 눈이 있는 것 같은 버튼은 보기모드이다.
    스와이프 도중 클릭 이벤트가 동작하는 케이스가 예전에 있었어서 개선 건으로 추가했다.

  2. x학년 y학기가 수평 정렬 되어있는 View 는 Scroll 가능한 탭이다.
    특정 학기를 선택 시, 해당 학기에 맞는 정보들이 갱신된다.

  3. 그 아래에는 학기 ItemView 이다. 각 학기에 맞는 정보와 과목들이 나온다.
    해당 과목들은 모두 Room 에 저장되어 있으며 각 과정시에 create, update, delete 를 실행한다.

  4. 각 학기로 이동할 때마다, 초기에 x학년 y학기, 총 학점, 취득 학점 등이 갱신된다.
    총 학점, 취득 학점의 경우 아이템에 값을 입력하고 다른 곳을 터치할 때에도 즉시 갱신된다. (update)

  5. 각 과목 아이템 레이아웃에 swipe 기능이 있으며, swipe 시 action 버튼이 보여진다.
    (현재는 삭제(delete)만 가능하다)

  6. + 버튼을 통해 학기 내 과목을 추가할 수 있다. (create)

초기 작업 및 고민

여기서도 MVP -> MVVM 작업은 어김없이 있었고, sharedPreference key 값 상수화 등 일부 코드 개선 작업도 병행했다.
이후 맞닥들인 내용은 기존의 방식과 다른 ViewPager 구현 방식이었다.

기존에는

TabLayoutViewPager 또는 ViewPager2 를 두고,
Pager 내에 들어가는 View 는 Fragment 를 사용했었다.

Compose 에서는

ScrollableTabRowHorizontalPager 를 두고,
Pager 내에 들어가는 View 는 Composable UI 로 만들어주면 되었다.

일단 Compose 에서 언급된 HorizontalPager 을 사용하기 위해 아래 라이브러리를 import 했다.

implementation "com.google.accompanist:accompanist-pager:0.19.0"
implementation "com.google.accompanist:accompanist-pager-indicators:0.19.0"

학점 입력 화면 (InputView)

어떻게 포스트를 작성할지 고민하다가 거대한 코드를 설명 부분에 맞춰 ... 으로 나누고
차례차례 이야기를 나아가보려 한다.
기타 글자색 설정이나, 크기를 설정했던 내용 등은 코드가 좀 길기에 일부 생략했다.

1. 변수부 선언

먼저 변수부 선언이다.

해당 파일에서만 사용할 수 있는 전역변수도 일부 활용했는데,
이는 내가 고민을 덜하고 작성한 것 같아 수정하려 한다.

// 1-1
/** 선택 다이얼로그에서 활용되는 정보 모음 */
private lateinit var inputSelectDialogInfo: InputDialogInfo.InputSelectDialogInfo

// 1-2
/** 삭제 다이얼로그에서 활용되는 정보 모음 */
private lateinit var inputDeleteDialogInfo: InputDialogInfo.InputDeleteDialogInfo

// 1-3
/** 유저가 마지막으로 입력한 값을 기억하기 위한 data class 기반 인스턴스 */
private var inputDisposedState = InputDisposedState()

@OptIn(ExperimentalPagerApi::class)
@Composable
fun InputView(vm: InputViewModel? = null) {
    inputDisposedState = InputDisposedState()

    val tabTitles = stringArrayResource(id = R.array.tb_input_tab_titles)

	// 2
    // ViewPager 의 현재 Page 정보. 1을 뺀 원본 index 값이 등록되어 있으므로 유의하여 작업할 것
    val pagerState = vm?.pagerState ?: rememberPagerState()
    // ViewPager 의 현재 Page index. 1을 뺀 원본 index 값이 등록되어 있으므로 유의하여 작업할 것
    val tabOriginalIndex = pagerState.currentPage

    // 애니메이션을 위한 코루틴 스코프
    val coroutineScope = rememberCoroutineScope()

	// 3
    val semesterIndex = vm?.semesterIndex?.observeAsState(1)
        ?: remember { mutableStateOf(0) }
    val columnIndex = remember { mutableStateOf(0) }

	// 4
    val revealedCardId = vm?.revealedCardId?.collectAsState()
        ?: remember { mutableStateOf(0) }
    val editSubject: (Int, Int, Int, String) -> Job = vm
        ?.let { it::editSubject } ?: { _: Int, _: Int, _: Int, _: String -> Job() }
    val getDialogItems: (InputDialogType) -> Array<String> = vm
        ?.let { it::getDialogItems } ?: { arrayOf() }

	// 5
    val focusManager = LocalFocusManager.current
    val viewModeEnabled = remember { mutableStateOf(false) }

    inputSelectDialogInfo = InputDialogInfo.InputSelectDialogInfo(
        openDialog = remember { mutableStateOf(false) },
        inputDialogType = remember { mutableStateOf(InputDialogType.GRADE) },
        selectItems = remember { mutableStateOf(listOf()) },
        semesterIndex = semesterIndex,
        editableRowIndex = remember { mutableStateOf(0) },
        columnIndex = columnIndex,
    )
    inputDeleteDialogInfo = InputDialogInfo.InputDeleteDialogInfo(
        openDialog = remember { mutableStateOf(false) },
        semesterIndex = semesterIndex,
        columnIndex = columnIndex,
    )
    ...
}
  1. 사실 추후 개선건이라고 보아도 될 듯 하다.

    1-1, 1-2
    dialog 가 열렸는지에 대한 값, dialog 의 타입, 선택한 행열 index 등의 값을 state 형태로 관리했었고,
    이런 state 들을 모아둔 data class 인스턴스이다.
    dialog 정보를 학점 및 과목 입력 화면에서 알고 있어야 하기에 이렇게 작성했었는데,
    차라리 ViewModel 에 놓거나, 해당 값을 계속 하위로 넘겨주는 게 나을 것 같다는 생각이 들기도 했다.
    이 관리 방법은 어떻게 할지 고민해보려 한다.

    1-3
    이건 직접 state 를 넣는게 아닌 primitive 유사값 (String, int 등) 들을 관리하는 인스턴스이다.
    이것도 1-1, 1-2 의 해결책이 그려지면 같이 작업하지 않을까 싶다.
    그래도 이건 state 타입 변수들이 아니다 보니, 1-1, 1-2 보단 나은 것 같다.

  2. HorizontalPager 에서 스크롤을 제어하고 관찰하기 위한 State object 이다.
    현재 보여지는 Index 를 가져오거나 새로 설정하기도 하고, 애니메이션 효과를 주거나, TabIndicator 과 연결도 될 수 있다.
    ViewPager 로 비유하면 음... adapter..? (정확히 같은 기능은 아니다.)

    ViewModel 에 hoisting 되어 있네요..?

    난 해당 값을 원래 내부에서 썼었다가 ViewModel 로 hoisting 해서 사용했다.
    ViewModel 차원에서 현재 page index 를 확인해야 해서 그렇게 처리했다.

    이로 인해 우려되는 점이 있다면, View 의 로직을 ViewModel 에서 제어할 수 있다는 단점이 있긴하다.
    실제 animateScrollToPage 를 통해 페이지를 이동시켜줄 수 있는데 이건 View 의 로직이라 고민이 있었다.

    하지만 현재 view 의 Index 를 정확히 알기 위해서 이런 식으로 hoisting 하여 사용했다.

    주석 내용을 보충설명하자면,
    2018년도의 나는 학기 index 를 0부터 하지않고 1부터 시작했었다.
    그러다보니 저런 주석이 추가되었고, 실제 이를 컨트롤하기위한 코드가 내부에 들어가 있다.
    마이그레이션하면서 이것도 같이 건드릴까했었지만, 1부터 시작하는 게 잘못된 건 또 아니고
    괜히 긁어 부스럼을 만드는 것 같아 그대로 두었다.

  3. 현재 학기 index 와, 선택된 열 index 는 여기에서 state 를 관리했다.
    나중에 후술하겠지만 최소한의 state 변수들을 최상위 Composable 에 두려고 했었고,
    해당 state 는 학기 ItemView 를 그리거나, 선택한 열에 맞는 팝업을 띄우기 위해 필요했기에 여기에 작성했다.

  4. action 이 보여지는 cardId state 도 여기에서 관리했다.
    전체 학기 차원에서, 오직 하나의 열만 action 이 보여야하기에 여기에서 관리했다.
    만약 각 학기마다 action 이 보여져도 상관 없다면 하위에 넣었을 것 같다.

    그 밑에는 과목 데이터 변경 기능, dialog 에 띄워줄 항목을 가져오는 기능을 변수화했다.
    이들은 모두 ViewModel 에 정의되어 있는 함수이다.

  5. Compose 에서는 FocusManager 를 통해 focus 를 관리한다.
    LocalFocusManager.current 를 통해 focus 되어있는 view 를 가져올 수 있고
    LocalFocusManager.current.moveFocus(..) 를 통해 특정 방향으로 focus 를 이동할 수도 있다.

    focus 는 modifier 에도 설정이 가능하다.
    포커스를 요청할 수 있게 만들거나(Modifier.focusRequester()),
    포커스가 되거나 잃을 시 어떻게 처리할지(Modifier.onFocusChanged())를 명세할 수 있다.

    다른 버튼을 클릭할 때, 변경된 내역을 과목에 반영하면서 총 학점과 취득 학점을 계산 후 갱신해야 하고
    focus 를 가질 수 없는 view 에 focus 를 부여해야 했기 때문에, 난 위에 언급된 일부 기능들을 사용했다.

2. Custom Toolbar

기존에는 위에 제목만 가운데에 있는 Toolbar 를 구현하면 되었지만
이제는 왼쪽, 오른쪽에 버튼이 있는 Toolbar 를 구현할 필요가 있었다.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun InputView(vm: InputViewModel? = null) {
    ...
    BoxWithConstraints() {
        val constraints = ConstraintSet {
            val btnInputAdd = createRefFor(btn_input_add)
            constrain(btnInputAdd) {
                bottom.linkTo(parent.bottom)
                end.linkTo(parent.end)
            }
        }

        ConstraintLayout(constraints) {
            Column(modifier = Modifier.background(color = Color.White)) {
                // 1
                Toolbar(
                    navigationIcon = { vm?.let { BackButton(vm) } },
                    titleRes = R.string.tv_input_toolbar_title,
                    actions = {
                        Image(
                            painterResource(...),
                            contentDescription = btn_viewer_toggle,
                            modifier = Modifier
                                .clickable(
                                    enabled = true,
                                    interactionSource = remember { MutableInteractionSource() },
                                    indication = rememberRipple(bounded = true),
                                    onClick = {
                                        focusManager.clearFocus()
                                        vm?.onItemClear()
                                        viewModeEnabled.value = !viewModeEnabled.value
                                    }
                                )
                                .padding(16.dp),
                        )
                    }
                )
                ...

구글에서 검색을 하다가 좋은 코드와 설명 링크를 발견했고 이를 일부 개조하여 사용하였다.
(설명링크는 유실되어서 참고에 언급하지 못했다..)

navigationIcon 을 통해 좌측의 버튼을 명세할 수 있고, actions 를 통해 우측의 버튼을 명세할
수 있다.

actions 에 명세한 버튼은 상단에 언급했던 보기모드 버튼으로
클릭 시 focus 를 clear 하여 변경 내역을 갱신하고, 삭제 버튼이 보이는 열도 모두 닫고
viewModeEnabled 값을 바꿔, 앞에 투명한 창이 보이도록 하여 터치를 할 수 없도록 막았다.

3. Scroll 가능한 수평정렬 탭

ScrollableTabRow 을 활용하여 구현하였다.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun InputView(vm: InputViewModel? = null) {
				...
                // 1
                ScrollableTabRow(
                    selectedTabIndex = tabOriginalIndex,
                    backgroundColor = Color.White,
                    edgePadding = 0.dp,
                    modifier = Modifier.height(input_item_action_icon_size),
                    indicator = { tabPositions ->
                        TabRowDefaults.Indicator(
                            color = colorResource(id = R.color.colorTheme),
                            height = 2.dp,
                            modifier = Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
                        )
                    }
                ) {
                    // 2
                    tabTitles.forEachIndexed { titleIndex, title ->
                        Tab(
                            selected = tabOriginalIndex == titleIndex,
                            onClick = {
                                focusManager.clearFocus()
                                coroutineScope.launch { pagerState.animateScrollToPage(titleIndex) }
                            },
                            text = {
                                Text(
                                    text = title,
                                    fontFamily = nanumSquareFamily,
                                    letterSpacing = 0.16.sp
                                )
                            },
                            selectedContentColor = colorResource(id = R.color.black),
                            unselectedContentColor = colorResource(id = R.color.statisticTabColor),
                            icon = null
                        )
                    }
                }

                Divider(...)
                
                ...
  1. 선택된 tabIndex, 배경색 등 xml 에서 설정할 수 있는 내용들은 거의 다 살정할 수 있다.
    Pager 와의 연동은 indecator 설정을 통해 가능하다. (pagerTabIndicatorOffset())

  2. 학기 개수에 맞는 Tab Composable 를 만들 수 있도록 했다.
    클릭 이벤트라거나, 색상 설정 등 역시 가능하다.

4. 학기 ItemView

기존의 Fragment 에서 보여주었던 내용들을 여기에서 구현하였다.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun InputView(vm: InputViewModel? = null) {
				...
				// 1
                HorizontalPager(
                    state = pagerState,
                    modifier = Modifier.weight(1f),
                    count = Const.GRADE_END
                ) { tabOriginalIndex ->
                    if (pagerState.currentPage != tabOriginalIndex) return@HorizontalPager

					// 2
                    val semesterNum = tabOriginalIndex + 1

					// 3
                    val subjects = vm?.allSubjects?.get(semesterNum)?.observeAsState()
                        ?: remember { mutableStateOf(listOf()) }
                    val totalScore = vm?.allTotalScore?.get(semesterNum)?.observeAsState()
                        ?: remember { mutableStateOf(0f) }
                    val totalGrade = vm?.allTotalGrade?.get(semesterNum)?.observeAsState()
                        ?: remember { mutableStateOf(0) }

                    Column(
                        modifier = Modifier.fillMaxSize(),
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
    	                Spacer(여백)
	                    Image(책 이미지)

                    	Spacer(여백)
                	    Text(x학년 y학기)

            	        Spacer(여백)
        	            Row(중앙 정렬) {
                        	Text(총 학점)
                    	    Spacer(여백)
                	        Text(
            	                text = String.format("%.2f", totalScore.value),
        	                    fontSize = 12.sp,
    	                        color = colorResource(id = R.color.themeTextColor)
	                        )
                        	Spacer(여백)
                    	    Text(취득 학점)
                	        Spacer(여백)
            	            Text(
        	                    text = "${totalGrade.value}",
    	                        fontSize = 12.sp,
	                            color = colorResource(id = R.color.themeTextColor)
                        	)
                        }

                        Spacer(modifier = Modifier.height(20.dp))
                        InputItemTitleView()
                        // 4
                        LazyColumn(modifier = Modifier.fillMaxHeight()) {
                        	// 5
                            items(count = subjects.value?.size ?: 0) { subjectIndex ->
                                val subject = subjects.value?.get(subjectIndex) ?: Subject()
                                val getItemIdAction: () -> Int? = {
                                    vm?.getItemSId(semesterNum, subjectIndex)
                                }
                                Box(Modifier.fillMaxWidth()) {
                                	// 6
                                    InputItemActionsRow(
                                        modifier = Modifier
                                            .fillMaxWidth()
                                            .padding(
                                                start = input_item_view_margin_horizontal,
                                                end = input_item_view_margin_horizontal
                                            ),
                                        subjectIndex = subjectIndex,
                                        subject = subject,
                                        onDelete = vm?.let { inputDeleteDialogInfo::update },
                                        inputStateMap = vm?.inputFieldStateMap,
                                    )
                                    
									// 7
                                    InputItemView(
                                        editSubject = editSubject,
                                        getDialogItems = getDialogItems,
                                        subject = subject,
                                        semesterNum = semesterNum,
                                        subjectIndex = subjectIndex,
                                        viewModeEnabled = viewModeEnabled,
                                        isRevealed = !viewModeEnabled.value && revealedCardId.value == subject.sId,
                                        cardOffset = input_item_action_icon_size.value.dpToPx(),
                                        getItemIdAction = getItemIdAction,
                                        onExpand = vm?.let { it::onItemExpanded },
                                        onCollapse = vm?.let { it::onItemCollapsed },
                                        inputStateMap = vm?.inputFieldStateMap,
                                    )
                                }
                            }
                        }
                    }
                    // 8
                    if (viewModeEnabled.value)
                        Divider(
                            modifier = Modifier
                                .fillMaxSize()
                                .clickable(
                                    interactionSource = remember { MutableInteractionSource() },
                                    indication = null,
                                ) {},
                            color = colorResource(id = R.color.transparent)
                        )
                }
            }
            ...
  1. 수평형 ViewPager 이다.
    pagerState 를 설정하고, pager 개수를 설정할 수 있다.

  2. 해당 page index 가 아닌 경우엔 그려지지 않도록 했다.
    좌우가 모두 보여야 할 필요는 없을 것 같아 이렇게 구현했었다.
    이건 옵셔널하게 다르게 구현할 필요가 있다면 손을 보려한다.

  3. 학기 과목 목록, 총 학점, 취득 학점은 여기에서 상태 관리를 하였다.
    상위에서 관리하면 그 상위에서부터 recomposition 을 했어서, 최대한 하위에서 동작하도록 했다.

  4. LazyColumn 은 화면에 보여지는 Composable 만을 보여주면서 scroll 이 가능한 Column 이다.
    일전에 state 에서 한번 언급이 되었던 Composable UI 이기도 하다.

  5. LazyColumn 특성 상 list 를 구현 시 많이 사용한다고 한다.
    추후 여기에 key 를 적용하여 새로 인스턴스가 생기는 걸 줄이려고 한다.

  6. 해당 과목의 action 버튼 (삭제 버튼) View 이다.
    처음에는 구현하면서 시행착오로 여기에 있지만, 추후 개선건으로 과목 ItemView 내에 넣으려고 한다.
    참고로 삭제 버튼을 누르면 DialogInfo 내부 state 값을 갱신하여 삭제 확인 dialog 가 띄워진다.
    (Dialog Info 값에 대해서는 Dialog 를 이야기하면서 언급할 예정이다.)

  7. 성적, 이수구분, 학점, 과목명을 입력할 수 있는 View 이다.
    겹치는 기능도 있고, 각 View 마다 focus 를 체크해야 하여 row 형태로 구현했다.
    (자세한 내용은 과목 ItemView 를 이야기하면서 다룰 예정이다.)

  8. viewMode 가 활성화될 시 보여지는 투명한 창이다.
    viewModeEnabled state 값에 의해 보여지거나 가려진다.
    클릭 이벤트가 명세되어 있는 건...
    예전에 클릭해도 무슨 동작하게 해야겠다고 했었는데 무산되면서 남은 코드여서 제거 예정이다.

5. + 버튼 및 기타 UI

+ 버튼과 경우에 따라 보여지고 가려지는 Dialog 를 여기에 명세하였다.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun InputView(vm: InputViewModel? = null) {
			...
			// 1
            if (!viewModeEnabled.value)
                Image(
                    painter = painterResource(id = R.drawable.ip_add_btn),
                    contentDescription = btn_input_add,
                    modifier = Modifier
                        .layoutId(btn_input_add)
                        .padding(end = 20.dp, bottom = 20.dp)
                        .clickable(
                            enabled = true,
                            interactionSource = remember { MutableInteractionSource() },
                            indication = rememberRipple(bounded = true),
                            onClick = {
                                focusManager.clearFocus()
                                vm?.addSubject(tabOriginalIndex + 1)
                            }
                        )
                )

			// 2
            InputSelectDialogView(vm, inputSelectDialogInfo)
            InputDeleteDialogView(vm, inputDeleteDialogInfo)
        }
    }

	// 3
    DisposableEffect(Unit) {
        Timber.i("DisposableEffect: entered input")

        onDispose {
            Timber.i("DisposableEffect: exited input")

            if (inputDisposedState.isValid)
                vm?.editSubject(
                    inputDisposedState.semesterIndex!!,
                    inputDisposedState.editableRowIndex!!,
                    inputDisposedState.columnIndex!!,
                    inputDisposedState.value!!,
                )
            inputDisposedState = InputDisposedState()
        }
    }
}
  1. viewMode 가 비활성화 상태일 시 보여지는 + 버튼이다.

  2. 선택 dialog, 삭제 dialog 이다.
    (자세한 내용은 과목 Dialog 를 이야기하면서 다룰 예정이다.)

  3. Compose 가 dispose 되었을 때 동작해야 하는 내용을 명세한 Composable 이다.
    해당 화면 종료 시 일전의 과목 변경 내역들을 갱신해야 하기 때문에 DisposableEffect 를 추가했다.

과목 ItemView (InputItemView)


그림 상으로 사각형 영역에 대한 View 이다.
좌측으로 스와이프 했을 때는 우측과 같이 삭제 버튼이 보인다.

추후 개선건으로 위의 InputView 와 분리할 생각을 가지고 있다.
한 파일에 코드가 많아졌기에 가독성이 떨어져서 그렇게 생각했었는데, 필수는 아니어서 고민하고 있다.

1. 전체

위에서 보았다시피 과목 ItemView 에는 실제 Content 를 보여주는 View, 삭제 버튼을 보여주는 View 가 있다.
추후 개선건으로 삭제 버튼을 보여주는 View 또한 해당 영역 내로 옮길 예정이다.

/**
 * 학점 계산하기 화면 아이템 내 각각의 열 View
 * 0,1 index 는 비활성화되어 있는 [BasicTextField]
 * 2,3 index 는 활성화되어 있는 [BasicTextField]
 *
 * @param editSubject 해당 학년 학기에 맞는 과목을 수정하는 함수 변수
 * @param getDialogItems 선택 Dialog 에서 띄워주어야 하는 아이템 목록을 가져오는 함수 변수
 * @param subject 아이템에 들어가는 과목 데이터
 * @param semesterNum "((semesterIndex + 1) / 2 학년 (semesterIndex % 2) 학기" 공식에서 semesterNum
 * @param subjectIndex 해당 학기 내 과목[Subject] index
 * @param viewModeEnabled 보기 모드가 활성화 되어 있는지에 대한 여부 state 값
 * @param isRevealed 해당 과목에 actionsRow 가 노출되어있는지에 대한 여부
 * @param cardOffset actionsRow 의 총 너비
 * @param onExpand 펼쳐졌을 때의 action
 * @param onCollapse 닫혔을 때의 action
 */
@SuppressLint("UnusedTransitionTargetStateParameter")
@Composable
fun InputItemView(
    editSubject: (Int, Int, Int, String) -> Job,
    getDialogItems: (InputDialogType) -> Array<String>,
    subject: Subject,
    semesterNum: Int,
    subjectIndex: Int,
    viewModeEnabled: MutableState<Boolean>,
    isRevealed: Boolean,
    cardOffset: Float,
    getItemIdAction: () -> Int?,
    onExpand: ((sId: Int) -> Unit)?,
    onCollapse: ((sId: Int) -> Unit)?,
    inputStateMap: MutableMap<Pair<Int?, Int>, MutableState<String>>?
) {
	// 1
    val offsetX = remember { mutableStateOf(0f) }
    val transitionState = remember {
        MutableTransitionState(isRevealed).apply { targetState = !isRevealed }
    }
    val transition = updateTransition(transitionState, label = "")
    val offsetTransition by transition.animateFloat(
        label = "cardOffsetTransition",
        transitionSpec = { tween(durationMillis = 250) },
        targetValueByState = { if (isRevealed) -cardOffset else 0f },
    )

    Card(
        modifier = Modifier
            .padding(
                start = input_item_view_margin_horizontal,
                end = input_item_view_margin_horizontal
            )
            // 2
            .offset { IntOffset((offsetX.value + offsetTransition).roundToInt(), 0) }
            // 3
            .pointerInput(Unit) {
                detectHorizontalDragGestures { change, dragAmount ->
                    if (viewModeEnabled.value) return@detectHorizontalDragGestures
                    when {
                        dragAmount <= -6 -> onExpand?.let { getItemIdAction()?.apply { it(this) } }
                        dragAmount > 6 -> onCollapse?.let { getItemIdAction()?.apply { it(this) } }
                    }
                }
            },
        elevation = 0.dp,
        shape = RoundedCornerShape(0.dp),
        content = {
            InputItemContentView(
                editSubject,
                getDialogItems,
                subject,
                semesterNum,
                subjectIndex,
                inputStateMap
            )
        },
    )
}
  1. swipe 액션에 대한 내용을 명세한 것이다.
    offsetX 는 항목 레이아웃의 시작점이므로 사실상 무조건 0이다.

    transitionState 는 MutableTransitionState 타입 변수이다.
    내부에는 초기 state (initialState), 현재 state(currentState), target state (targetState) 이렇게 가지고 있다.
    당시에는 상태를 2개를 둔다고 이렇게 targetState 와 initialState 를 다르게 두었는데
    지금 보니 굳이 다르게 둘 필요가 없어 추후 개선건으로 apply {..} 코드를 제거할 예정이다.

    이후 updateTransition 을 통해 현재 currentState 를 갱신한 transition 값을 가져온다.
    그리고 transition 을 기반으로 Float 기반 애니메이션을 만든다.
    transitionSpec 을 통해 몇 초동안 애니메이션을 실행할 것인지, targetValueByState 로 어떤 값을 반환할 것인지를 정할 수 있다.

  2. 1번 과정을 통해 offsetTransition 의 결과값으로 targetValueByState 값을 가져오고
    삭제 버튼이 보이느냐 안 보이느냐에 따라, 왼쪽으로 과목 ItemView 를 옮겨주게 된다.

  3. pointerInput 을 통해 tap, pressed, longPressed 등에 대한 이벤트 처리를 할 수 있다.
    예전 activity 에서 dispatchTouchEvent(ev: MotionEvent?) 와 유사해보였다.
    기존과 달리 Jetpack Compose 는 각 view 차원에서 이런 처리가 가능하다,

    어쨌거나 나는 drag 을 어디로 했느냐에 따라, 삭제 버튼이 보이는 과목 id 를 갱신해야 했으므로 해당 처리를 해주었다.
    -6, 6을 둔건 미세한 드래그는 무시하기 위해 임계값을 두었다.
    추후 개선건으로 임계값을 상수화 시키는 게 좋을 것 같다.

2. 실제 Content 를 보여주는 View

과목열 Content 를 보여주는 View 이다. 특정 항목을 선택하거나 직접 타이핑으로 값을 입력할 수도 있다.

/**
 * 학점 계산하기 화면 아이템 View 에서 데이터를 보여주는 레이아웃
 *
 * @param editSubject 해당 학년 학기에 맞는 과목을 수정하는 함수 변수
 * @param getDialogItems 선택 Dialog 에서 띄워주어야 하는 아이템 목록을 가져오는 함수 변수
 * @param subject 아이템에 들어가는 과목 데이터
 * @param semesterIndex "((semesterIndex + 1) / 2 학년 (semesterIndex % 2) 학기" 공식에서 semesterIndex
 * @param columnIndex 아이템 행 index
 * @param inputStateMap 아이템 행 index
 */
@Composable
fun InputItemContentView(
    editSubject: (Int, Int, Int, String) -> Job,
    getDialogItems: (InputDialogType) -> Array<String>,
    subject: Subject?,
    semesterIndex: Int,
    columnIndex: Int,
    inputStateMap: MutableMap<Pair<Int?, Int>, MutableState<String>>?,
) {
    Column {
        // 1
        Row(modifier = Modifier.fillMaxWidth()) {
            InputItemEditableView(
                editSubject,
                getDialogItems,
                0,
                subject,
                Modifier.weight(10f),
                stringResource(id = R.string.tv_input_item_grade_hint),
                semesterIndex,
                columnIndex,
                inputStateMap,
            )
            InputItemEditableView(
                editSubject,
                getDialogItems,
                1,
                subject,
                Modifier.weight(20f),
                stringResource(id = R.string.tv_input_item_category_hint),
                semesterIndex,
                columnIndex,
                inputStateMap,
            )
            InputItemEditableView(
                editSubject,
                getDialogItems,
                2,
                subject,
                Modifier.weight(10f),
                stringResource(id = R.string.tv_input_item_score_hint),
                semesterIndex,
                columnIndex,
                inputStateMap,
            )
            InputItemEditableView(
                editSubject,
                getDialogItems,
                3,
                subject,
                Modifier.weight(40f),
                stringResource(id = R.string.tv_input_item_name_hint),
                semesterIndex,
                columnIndex,
                inputStateMap,
            )
        }
        Divider(
            modifier = Modifier
                .fillMaxWidth()
                .height(0.5.dp)
                .background(color = colorResource(id = R.color.grayDefaultColor))
        )
    }
}
  1. 성적, 이수구분, 학점, 과목명의 차이는 아래와 같다

    • 성적 : 선택 시 팝업을 띄워주어 성적 (ex. A+, B 등) 을 입력할 수 있어야함
    • 이수구분 : 선택 시 팝업을 띄워주어 성적 (ex. 전공, 교양 등) 을 입력할 수 있어야함
    • 학점 : 숫자를 입력할 수 있음
    • 과목명 : 일반 문자열을 입력할 수 있음

    각 4개가 미묘하게 다르면서도 역할은 비슷하게 묶을 수 있었어서,
    이 4개를 통합하는 View (InputItemEditableView) 를 만들어 처리하기로 결정했다.

    추후 개선한다면 성적, 이수구분 / 학점, 과목명 이렇게 분리할 수 있을 것 같다.
    그런데 저 4개에서 생기는 공통점도 있기 때문에 공통 코드들도 많이 생기기 때문에
    분리하는 게 정답인지는 아직 모르겠다.

1. 과목 ItemView 의 각 열 View

위 1번에서 이야기했던 과목 ItemView 의 각 열 View 이다.
그림상에서 네모 영역 각각을 이야기한다.

/**
 * 학점 계산하기 화면 아이템 내 각각의 열 View
 * 0,1 index 는 비활성화되어 있는 [BasicTextField]
 * 2,3 index 는 활성화되어 있는 [BasicTextField]
 *
 * @param editSubject 해당 학년 학기에 맞는 과목을 수정하는 함수 변수
 * @param getDialogItems 선택 Dialog 에서 띄워주어야 하는 아이템 목록을 가져오는 함수 변수
 * @param editableRowIndex 아이템내 열 index
 * @param subject 아이템에 들어가는 과목 데이터
 * @param modifier [Modifier]
 * @param hintText 힌트 문자열
 * @param semesterIndex "((semesterIndex + 1) / 2 학년 (semesterIndex % 2) 학기" 공식에서 semesterIndex
 * @param columnIndex 아이템 행 index
 * @param inputStateMap 아이템 행 index
 */
@Composable
fun InputItemEditableView(
    editSubject: (Int, Int, Int, String) -> Job,
    getDialogItems: (InputDialogType) -> Array<String>,
    editableRowIndex: Int,
    subject: Subject?,
    modifier: Modifier,
    hintText: String,
    semesterIndex: Int,
    columnIndex: Int,
    inputStateMap: MutableMap<Pair<Int?, Int>, MutableState<String>>?,
) {
	// 1
    val disabled = editableRowIndex == 0 || editableRowIndex == 1

	// 2
    val disabledValue = when (editableRowIndex) {
        0 -> subject?.sGrade
        1 -> subject?.sCategory
        else -> ""
    } ?: ""
    val disabledModifier = modifier
        .height(input_item_action_icon_size)
        .padding(top = input_item_view_padding, bottom = input_item_view_padding)
        .fillMaxWidth()
        .indication(
            indication = rememberRipple(bounded = true),
            interactionSource = remember { MutableInteractionSource() }
        )
	
    // 3
    val enabledValue = if (disabled) null
    else inputStateMap?.get(Pair(subject?.sId, editableRowIndex)) ?: remember {
        mutableStateOf(
            value = when (editableRowIndex) {
                2 -> if (subject?.sScore == 0) "" else "${subject?.sScore}"
                3 -> subject?.sName
                else -> ""
            } ?: ""
        ).apply { inputStateMap?.set(Pair(subject?.sId, editableRowIndex), this) }
    }
    val enabledModifier = if (disabled) null else {
        val focusRequester = remember { FocusRequester() }
        val focusValue = remember { mutableStateOf<Boolean?>(null) }

        disabledModifier
            .onFocusChanged {
                Timber.i(" semesterIndex : $semesterIndex columnIndex : $columnIndex editableRowIndex : $editableRowIndex focusMode : $it")
                // focus 를 잃었을 때 해당 데이터를 갱신해주고 키보드를 내린다.
                if (!disabled && focusValue.value == true && !it.hasFocus && !it.isFocused) {
                    editSubject(semesterIndex, editableRowIndex, columnIndex, enabledValue!!.value)
                    focusValue.value = false
                } else if (it.isFocused) {
                    focusValue.value = true
                }
            }
            .focusRequester(focusRequester)
    }

	val focusManager = LocalFocusManager.current
    // 4
    BasicTextField(
        value = if (disabled) disabledValue else enabledValue!!.value,
        onValueChange = {
	        // 5
            if (validateInputCheck(editableRowIndex, it)) {
                enabledValue?.value = it
                inputSelectDialogInfo.editableRowIndex.value = editableRowIndex
                inputSelectDialogInfo.columnIndex.value = columnIndex

                inputDisposedState = if (!disabled)
                    InputDisposedState(
                        semesterIndex = semesterIndex,
                        editableRowIndex = editableRowIndex,
                        columnIndex = columnIndex,
                        value = enabledValue!!.value,
                    )
                else InputDisposedState()
            }
        },
        modifier = (if (disabled) disabledModifier else enabledModifier!!).clickable(
            enabled = true,
            interactionSource = remember { MutableInteractionSource() },
            indication = rememberRipple(bounded = true),
            // 6
            onClick = {
                focusManager.clearFocus()
                when (editableRowIndex) {
                    0 -> {
                        inputSelectDialogInfo.update(
                            openDialog = true,
                            inputDialogType = InputDialogType.GRADE,
                            selectItems = getDialogItems(InputDialogType.GRADE).toList(),
                            editableRowIndex = editableRowIndex,
                            columnIndex = columnIndex
                        )
                    }
                    1 -> {
                        inputSelectDialogInfo.update(
                            true,
                            InputDialogType.CATEGORY,
                            getDialogItems(InputDialogType.CATEGORY).toList(),
                            editableRowIndex,
                            columnIndex,
                        )
                    }
                    else -> {}
                }
            }
        ),
        // 7
        enabled = editableRowIndex == 2 || editableRowIndex == 3,
        textStyle = TextStyle(
            fontSize = 13.sp,
            textAlign = TextAlign.Center,
            color = colorResource(id = R.color.inputTextColor),
        ),
        decorationBox = { innerTextField ->
            if ((disabled && disabledValue.isEmpty()) || (!disabled && enabledValue!!.value.isEmpty())) {
                Text(
                    text = hintText,
                    fontSize = 13.sp,
                    color = colorResource(id = R.color.inputHintColor),
                    textAlign = TextAlign.Center
                )
            }
            innerTextField()
        },
        cursorBrush = SolidColor(colorResource(id = R.color.themeTextColor)),
    )
}

// 5
/** 입력한 값의 유효성을 체크한다. 2는 일반 문자열. 3은 숫자만을 받는다. */
private fun validateInputCheck(editableRowIndex: Int, value: String): Boolean {
    val pattern = Pattern.compile(
        when (editableRowIndex) {
            2 -> "[0-9]*"
            3 -> "[0-9a-zA-Z|ㄱ-ㅎㅏ-ㅣ가-힣\\u318D\\u119E\\u11A2\\u2022\\u2025" +
                "\\u00B7\\uFE55\\u4E10\\u3163\\u3161 ]*"
            else -> return false
        }
    )
    return when (editableRowIndex) {
        2 -> pattern.matcher(value).matches() && value.length < 3
        3 -> pattern.matcher(value).matches()
        else -> false
    }
}

성적 = 0, 이수구분 = 1, 학점 = 2, 과목명 = 3 으로 생각하여 진행하였다.
추후 개선건으로 상수화가 필요해보인다.

  1. 위 내용에 따라 입력 기능을 활성화할건지, 안할건지에 대한 flag 이다.
    성적, 이수구분은 직접 텍스트를 입력하면 안되므로 이렇게 flag 를 두었다.

  2. 비활성화/활성화 여부에 따라 값과 modifier 를 다르게 처리해야 한다.
    비활성화 영역 value 와 modifier 초기화를 해주었고, 활성화 영역은 3번에서 처리된다.

  3. 위에서 말했다시피 활성화 영역 value 와 modifier 초기화를 해준다
    활성화 영역 modifier 는 disabledModifier 의 기능을 온전히 가져야 한다.

    그래서 disabledModifier 을 기본 선언하고,
    거기에 추가 기능을 덧붙여 enabledModifier 를 만들었다.
    활성화 영역에서는 포커스를 잃거나 얻었을 때의 추가 작업이 필요하므로 이 처리를 추가해주었다.

  4. 기존의 TextField 를 못쓰고 상위 TextField 를 썼다는 게 바로 이 이야기이다.
    기존에는 TextField 로도 처리가 가능했지만, 기본 padding 을 0으로 할 수 없는 등의 이슈가 있어 TextField 의 상위인 BasicTextField 를 사용했다.
    하지만 처음에만 어려웠지, 검색을 하면서 부족한 부분들을 채워나갔고 실제 그렇게 어려운 부분은 아니었다.
    이 과정으로 기존 Composable UI 로 한계가 있을때의 처리 방법을 깨달았다.

  5. 이건 왠지 더 좋을 방법이 있을 것 같기도 하다. 입력 시 정규식을 통해 입력을 하도록 했다.
    입력 성공 시 Composable UI 내 텍스트를 갱신하고,
    학점 입력 화면이 disposed 되었을 때 넣어줄 값을 갱신하도록 했다.
    키보드 상에서 학점을 클릭하면 숫자가 아닌 일반 입력 키보드가 나오는데, 이 부분은 추후 개선건으로 두었다.

  6. 클릭시에 대한 처리이다.
    성적, 이수구분을 클릭 시 그에 맞는 항목들이 나와야 하기 때문에
    state 들을 모두 update 해주어, recomposition 을 발생시켜 Dialog 가 켜지도록했다.

  7. 학점, 과목명은 직접 타이핑을 칠 수 있어야 하므로 이렇게 두었다.
    (생각해보니 !disabled 를 하면 되는데 깜빡한 것 같다. 이것도 추후 개선건이다)

3. 과목 삭제 버튼을 보여주는 View

swipe 를 통해 보여주는 과정은 이야기를 했었다.
실제 어떻게 보여지는 지를 이야기해보려 한다.

/** 한 행의 Action Row. 현재는 삭제밖에 없다. */
@Composable
fun InputItemActionsRow(
    modifier: Modifier,
    subjectIndex: Int,
    subject: Subject,
    onDelete: ((Boolean, Int, String) -> Unit)?,
    inputStateMap: MutableMap<Pair<Int?, Int>, MutableState<String>>?,
) {
    val focusManager = LocalFocusManager.current
    Row(modifier = modifier, horizontalArrangement = Arrangement.End) {
        Image(
            modifier = Modifier
                .background(color = colorResource(id = R.color.grayDefaultColor))
                .size(input_item_action_icon_size)
                .padding(input_item_view_padding)
                .clickable(
                    enabled = true,
                    interactionSource = remember { MutableInteractionSource() },
                    indication = rememberRipple(bounded = true),
                    onClick = {
                        focusManager.clearFocus()
                        inputStateMap?.remove(Pair(subject.sId, 0))
                        inputStateMap?.remove(Pair(subject.sId, 1))
                        inputStateMap?.remove(Pair(subject.sId, 2))
                        inputStateMap?.remove(Pair(subject.sId, 3))
                        onDelete?.let { it(true, subjectIndex, subject.sName) }
                    }
                ),
            painter = painterResource(id = R.drawable.btn_input_delete),
            contentDescription = btn_input_delete
        )
    }
}

거창해보였겠지만 사실 별거 없다.
클릭 시 onDelete 에 적합한 수행을 해주는 것(삭제 dialog 띄우기) 를 해주는 것 밖에 없다.
오히려 추후 개선건들이 눈에 보인다.
inputStateMap 의 경우 진짜 삭제될때 해주어야 하는데 이 부분에서 하고 있어 개선해야 할 것 같다.
동작엔 이상은 없지만 취소시 remember 를 통해 없앴다 생겼다가 반복하기 때문에 성능? 상으로 개선이 필요할 듯 하다.

Dialog (InputSelectDialogView, InputDeleteDialogView)

선택 Dialog, 삭제 확인 Dialog 에 대한 이야기이다.
각 기능이 다르긴하지만 Dialog 라는 큰 틀은 차이가 없다.

1. 선택 Dialog


여러 항목중에서 하나를 선택하는 Dialog 이다.
성적, 이수구분 아이템을 선택할 수 있으며, 선택 후 해당 값이 특정 과목 항목 내에 적용된다.

/**
 * Input 화면 내에서 띄워주는 선택 Dialog 에서 사용되는 Composable 모음
 *
 * @author ricky
 * @since v3.0.0 / 2022.01.03
 */
@Composable
fun InputSelectDialogView(
    vm: InputViewModel? = null,
    inputDialogInfo: InputDialogInfo.InputSelectDialogInfo
) {
    MaterialTheme {
        // 1
        if (inputDialogInfo.openDialog.value)
            AlertDialog(
                onDismissRequest = { inputDialogInfo.openDialog.value = false },
                title = {
                    Text(
                        modifier = Modifier.padding(top = 10.dp, bottom = 10.dp),
                        text = inputDialogInfo.inputDialogType.value.title,
                        fontSize = 18.sp,
                        fontWeight = FontWeight(700),
                    )
                },
                text = {
                    Column {
                        Spacer(modifier = Modifier.height(20.dp))
                        LazyColumn {
                            items(count = inputDialogInfo.selectItems.value.size) { position ->
                                InputSelectDialogItemView(
                                    vm,
                                    inputDialogInfo.openDialog,
                                    inputDialogInfo.selectItems.value[position],
                                    inputDialogInfo.semesterIndex.value,
                                    inputDialogInfo.editableRowIndex.value,
                                    inputDialogInfo.columnIndex.value,
                                )
                            }
                        }
                    }
                },
                confirmButton = {}
            )
    }
}

@Composable
fun InputSelectDialogItemView(
    vm: InputViewModel?,
    openDialog: MutableState<Boolean>,
    selectItem: String,
    semesterIndex: Int,
    editableRowIndex: Int,
    columnIndex: Int,
) {
    Text(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(
                enabled = true,
                interactionSource = remember { MutableInteractionSource() },
                indication = rememberRipple(bounded = true),
                onClick = {
                    vm?.editSubject(semesterIndex, editableRowIndex, columnIndex, selectItem)
                    openDialog.value = false
                }
            )
            .padding(top = 10.dp, bottom = 10.dp),
        text = selectItem,
        fontSize = 15.sp,
        fontFamily = nanumSquareFamily,
        color = colorResource(id = R.color.black),
    )
}
  1. JetpackCompose Playground 에서 구현 방법을 참고하여 작성했다. (AlertDialog)
    state 를 통해 열리는지 닫히는지를 구분했고, 리스트를 보여주어야 하기에 LazyColumn 도 사용했다.

2. 삭제 확인 Dialog

정말 삭제를 할 것인지 물어보는 Dialog 이다.

/**
 * Input 화면 내에서 띄워주는 삭제 Dialog 에서 사용되는 Composable 모음
 *
 * @author ricky
 * @since v3.0.0 / 2022.01.03
 */
@Composable
fun InputDeleteDialogView(
    vm: InputViewModel? = null,
    inputDeleteDialogInfo: InputDialogInfo.InputDeleteDialogInfo,
) {
    MaterialTheme {
        if (inputDeleteDialogInfo.openDialog.value)
            AlertDialog(
                onDismissRequest = { inputDeleteDialogInfo.openDialog.value = false },
                title = {
                    Text(
                        modifier = Modifier.padding(top = 10.dp, bottom = 10.dp),
                        text = "${inputDeleteDialogInfo.subjectInfo} 과목을 삭제하시겠습니까?",
                        fontSize = 18.sp,
                        fontWeight = FontWeight(700),
                    )
                },
                dismissButton = {
                    Text(
                        modifier = Modifier
                            .clickable(
                                enabled = true,
                                interactionSource = remember { MutableInteractionSource() },
                                indication = rememberRipple(bounded = true),
                                onClick = { inputDeleteDialogInfo.openDialog.value = false }
                            )
                            .padding(12.dp),
                        text = "취소",
                        fontSize = 15.sp,
                        fontFamily = nanumSquareFamily,
                        color = colorResource(id = R.color.themeTextColor),
                    )
                },
                confirmButton = {
                    Text(
                        modifier = Modifier
                            .clickable(
                                enabled = true,
                                interactionSource = remember { MutableInteractionSource() },
                                indication = rememberRipple(bounded = true),
                                onClick = {
                                    vm?.removeSubject(
                                        inputDeleteDialogInfo.semesterIndex.value,
                                        inputDeleteDialogInfo.columnIndex.value
                                    )
                                    inputDeleteDialogInfo.openDialog.value = false
                                }
                            )
                            .padding(12.dp),
                        text = "확인",
                        fontSize = 15.sp,
                        fontFamily = nanumSquareFamily,
                        color = colorResource(id = R.color.themeTextColor),
                    )
                },
            )
    }
} 

위 선택 Dialog 와 큰 차이는 없다.

3. DialogInfo

아래와 같이 sealed class 를 통해 자식 클래스들을 만들었으며,
각 특징에 따라 다른 값들도 가질 수 있도록 하였다.

/**
 * InputView 에서 다이얼로그를 띄울 때 사용하는 최소 State 값 모음
 *
 * @param openDialog dialog 가 열렸는지 여부
 * @param semesterIndex "((semesterIndex + 1) / 2 학년 (semesterIndex % 2) 학기" 공식에서 semesterIndex
 * @param columnIndex 선택한 행의 index 값
 *
 * @author ricky
 * @since v3.0.0 / 2022.01.03
 */
sealed class InputDialogInfo(
    open val openDialog: MutableState<Boolean>,
    open val semesterIndex: State<Int>,
    open val columnIndex: MutableState<Int>,
) {
    /**
     * [InputView] 에서 선택 다이얼로그를 띄울 때 사용하는 State 값 모음
     *
     * @param openDialog dialog 가 열렸는지 여부
     * @param inputDialogType 지금 열린 dialog 의 타입 (성적, 이수구분)
     * @param selectItems 해당 dialog 에서 선택할 수 있는 item 목록
     * @param semesterIndex "((semesterIndex + 1) / 2 학년 (semesterIndex % 2) 학기" 공식에서 semesterIndex
     * @param editableRowIndex 선택한 열의 index 값 (0 : 성적, 1 : 이수구분, 2 : 학점, 3 : 과목명)
     * @param columnIndex 선택한 행의 index 값
     */
    data class InputSelectDialogInfo(
        override val openDialog: MutableState<Boolean>,
        val inputDialogType: MutableState<InputDialogType>,
        val selectItems: MutableState<List<String>>,
        override val semesterIndex: State<Int>,
        val editableRowIndex: MutableState<Int>,
        override val columnIndex: MutableState<Int>,
    ) : InputDialogInfo(openDialog, semesterIndex, columnIndex) {
        fun update(
            openDialog: Boolean,
            inputDialogType: InputDialogType,
            selectItems: List<String>,
            editableRowIndex: Int,
            columnIndex: Int,
        ) {
            this.openDialog.value = openDialog
            this.inputDialogType.value = inputDialogType
            this.selectItems.value = selectItems
            this.editableRowIndex.value = editableRowIndex
            this.columnIndex.value = columnIndex
        }
    }

    /**
     * [InputView] 에서 삭제 다이얼로그를 띄울 때 사용하는 State 값 모음
     *
     * @param openDialog dialog 가 열렸는지 여부
     * @param semesterIndex "((semesterIndex + 1) / 2 학년 (semesterIndex % 2) 학기" 공식에서 semesterIndex
     * @param columnIndex 선택한 행의 index 값
     * @param subjectInfo 삭제할 subject 정보 update 를 통해 항상 갱신시킨다.
     */
    data class InputDeleteDialogInfo(
        override val openDialog: MutableState<Boolean>,
        override val semesterIndex: State<Int>,
        override val columnIndex: MutableState<Int>,
        var subjectInfo: String = ""
    ) : InputDialogInfo(openDialog, semesterIndex, columnIndex) {
        fun update(openDialog: Boolean, columnIndex: Int, subjectInfo: String) {
            this.openDialog.value = openDialog
            this.columnIndex.value = columnIndex
            this.subjectInfo = subjectInfo
        }
    }
}

inputStateMap

화면에서의 내용들은 얼추 이야기한 것 같은데
중간중간 언급되는 inputStateMap 에 대한 설명이 필요해보여 이 파트를 만들었다.

결론적으로는 key 를 제대로 활용하지 못해 생긴 map 이다.
새로 recomposition 할 때 list 아이템들이 새로 그려지면서 매번 새로운 Composable UI 가 만들어졌고, 그로 인해 remember 을 통해 새로운 state 를 항상 만들게 되었다.

동작에는 문제가 없었지만 당시에는 매번 새로운 state 가 생기는 걸 막고 재활용하고 싶어 이렇게 했었다.
그런데 keys 설정을 해서 리스트 갱신때마다 모든 아이템들이 새 UI 가 그려지는 걸 막으면 되는 거였다.

빠르게 런칭하기 위해 깊게 바라보지 못했던 문제여서, 추후 개선건으로 이 작업도 같이 진행하려 한다.

결론

휴우.. 다 정리했다. 돌아보니 개선건들이 더 많이 보이는 건 뭔가...
앞으로 할 게 더 많다는 소리겠지 :)

사실 이 화면작업에서 하고 싶은 건 많았다. 드래그를 통해 과목의 위치도 옮길 수 있게 구현하려 했다.
사정상 반영은 못했고 추후 개선건으로 남아있다.

feature 도 feature 지만 지금 이렇게 쫙 돌아봤는데도 개선건들이 많이 보인다... (일부 주석도 수정이 필요해보인다.)
1월말 런칭을 목표로 했더니 그만큼 부작용도 많이 남았던 코드인 것 같아 아쉬움도 남는다.

그런 만큼 이 시리즈를 작성하는 것에 노력을 해야할 것 같다.
빨리 돌아보고 취합해서 개선해야겠다는 생각이 느껴지기에 열렬히 포스팅을 올려야 겠다는 생각을 했다.

다음 포스팅은 학점 통계 화면 이다.
여기에서는 거꾸로 Compose 내에서 기존 view 를 만들어서 처리하도록 했다.
예를 들면 compose 내에 TabLayout 구현하기?
학점 입력 화면과의 차이를 보기 위해 이렇게 구현했었는데 실제 차이를 느꼈다.
그 외에도 권한 설정, context 사용 방법, 기타 다양한 view 활용 내역들을 정리해서 포스팅할 예정이다.

참고

좋은 웹페이지 즐겨찾기