본문 바로가기

MoneyMong

기존 화면을 기반한 Onboarding 화면을 구현해보자

 

머니몽 프로젝트에 온보딩 화면이 추가 되었고, 이를 적용하기까지의 과정을 작성하려 한다.

온보딩 화면 구현 방법을 검색했을 때, 앱 시작 온보딩만 검색된다
필자의 요구 사항은 기존 화면을 기반한 온보딩 화면이라서 관련된 레퍼런스를 찾지 못해 꽤나 애를 먹었다

다른 누군가도 이와 유사한 기능을 구현한다면 미약하게나마 도움이 되지 않을까,, 기대한다

 

요구사항 파악

장부 화면으로 처음 들어갔을 때, 온보딩 화면이 보이도록 해달라는 요청이였다.

 

디자인은 다음과 같다.

왼쪽부터 기존 화면, 장부 열람 기간 온보딩, 장부 내역 등록 온보딩이다.

기존 화면, 장부 열람 기간 온보딩, 장부 내역 등록 온보딩

 

# 참고로 해당 프로젝트는 운영진, 멤버라는 두 가지의 역할이 존재한다.

 

여기서 파악할 건, 장부 내역 등록은 운영진일때만 가능하다.

그렇기에 장부 내역 등록 온보딩은 운영진일때만 보여지길 원하셨다.

 

하지만 이렇게만 파악하고 끝나면 안되기에,  PM님에게 아래처럼 질문드렸다.

 

각 역할마다 온보딩 화면이 한 번씩 구분돼서 떠야하냐는 질문이였고, PM님께서 가능하면 그렇게 해달라고 하셨다.

 

이렇게 해서 요구사항을 요약하면 다음과 같다.

 

1. 소속에 처음 들어갔을 때만 온보딩 화면 표시

    1-1. 역할(운영진, 멤버)마다 한 번씩.

2. 멤버로 소속에 처음 들어갔을 땐, 장부 열람 기간 온보딩만

3. 운영진으로 소속에 처음 들어갔을 땐, 장부 열람 기간 온보딩, 장부 열람 기간 온보딩 모두 표시

 

 

구현

| 참고로, 얘기하고 싶은 부분은 UI 영역이기에 data 영역은 간단하게 설명하려 한다.

 

data layer 설계 및 구현

사용자의 기기마다 Boolean 으로 장부 화면에 접근 여부를 저장하고, 이를 관찰할 수 있으면 되기에 PreferenceDataStore 를 사용했다.

https://developer.android.com/topic/libraries/architecture/datastore

 

앱 아키텍처: 데이터 영역 - Datastore - Android 개발자  |  Android Developers

데이터 영역 라이브러리에 관한 이 앱 아키텍처 가이드를 통해 Preferences DataStore 및 Proto DataStore, 설정 등을 알아보세요.

developer.android.com

 

코드는 아래와 같다. 

class LedgerLocalDataSourceImpl @Inject constructor(
    @Named("ledger") private val ledgerPreferences: DataStore<Preferences>
) : LedgerLocalDataSource {
    private object Key {
        val LEDGER_ONBOARDING_STAFF = booleanPreferencesKey("LEDGER_ONBOARDING_STAFF")
        val LEDGER_ONBOARDING_MEMBER = booleanPreferencesKey("LEDGER_ONBOARDING_MEMBER")
    }

    private val visibleOnboardingStaff: Flow<Boolean> =
        ledgerPreferences.data.map { preferences ->
            preferences[Key.LEDGER_ONBOARDING_STAFF] ?: true
        }

    private val visibleOnboardingMember: Flow<Boolean> =
        ledgerPreferences.data.map { preferences ->
            preferences[Key.LEDGER_ONBOARDING_MEMBER] ?: true
        }

    override fun fetchVisibleLedgerOnboarding(onboardingType: OnboardingType): Flow<Boolean> {
        return when (onboardingType) {
            OnboardingType.STAFF -> visibleOnboardingStaff
            OnboardingType.MEMBER -> visibleOnboardingMember
        }
    }

    override suspend fun postDisplayedLedgerOnboarding(onboardingType: OnboardingType) {
        ledgerPreferences.edit { preferences ->
            when (onboardingType) {
                OnboardingType.STAFF -> preferences[Key.LEDGER_ONBOARDING_STAFF] = false
                OnboardingType.MEMBER -> preferences[Key.LEDGER_ONBOARDING_MEMBER] = false
            }
        }
    }
}

 

key 값을 역할(멤버, 운영진)에 따라 나눠서 관리하고,

필요한 값과 저장할 값을 인자의 타입으로 구분하여 반환하고 저장한다.

 

참고로 접근 여부를 Flow<Boolean> 로 반환받아, UI 영역에서 새로 호출하지 않아도 collect 로 수집하고 있으면 접근 여부가 자동으로 업데이트 된다.

 

 

UI layer 설계 및 구현

| 두 가지 난관이 존재했으며, 이를 중점으로 기재

 

위 온보딩 화면 구현에서 마주친 난관은 다음과 같다.

 

1. 온보딩 화면이 Bottom Navigation 영역을 포함한 전체 화면을 차지해야 한다.

2. 기존 화면의 컴포넌트 위치 그대로 온보딩 화면에 띄워야 한다.

 

하나씩 살펴보자

 

난관 1. 온보딩 화면이 Bottom Navigation 영역을 포함한 전체 화면을 차지해야 한다.

현재 프로젝트에서 Bottom Navigation 은 최상위 Composable 에 존재하기에,

우리에게 주어진 영역은 Bottom Navigation 위 영역이다.(위 사진의 빨간색 영역)

하지만, 디자인 상으론 Bottom Navigation 영역까지 침범이 필요하고, 일반적인 방법으론 Compose 에서 상위 Composable 함수의 영역을 침범할 수 없다.

 

그런데 상위 Composable 의 영역을 무시하고 화면에 나타나는 친구가 있다.

 

- Dialog

- Popup

 

의미적 차이는

- Dialog 는 중요한 사용자 상호작용이 필요할 때

- Popup 은 간단한 정보 전달이나 임시 인터페이스를 제공할 때

 

실제 사용에서 차이는

Dialog 는 내부적으로 scrim 영역을 불투명하게 하지만, Popup 은 scrim 영역을 건드리지 않는다.

 

위 차이들로 Popup 이 더 알맞은 선택지라고 판단했다.

 

Popup Composable 의 주석을 살펴보면 다음과 같이 작성되어 있다.

Popup 주석

여기서 중요한 건 floating 이다.
현재 activity 의 최상단에 뜬다는 의미로, 사용하면 Bottom Navigation 영역을 무시할 수 있다.

 

다음과 같이 Popup 선언 내부에 온보딩 화면을 content 로 제공해서 원하는 UI 를 그릴 수 있었다.

Popup {
    when (currentPage) {
        LedgerOnboardingPage.DATE -> {
            LedgerOnboardingDatePage(
                modifier = modifier,
                dateComponent = dateComponent,
                currentDate = currentDate,
                onClickNext = {
                    if (isStaff) {
                        currentPage = LedgerOnboardingPage.ADD
                    } else {
                        onDismiss()
                    }
                }
            )
        }

        LedgerOnboardingPage.ADD -> {
            LedgerOnboardingAddPage(
                modifier = modifier,
                addComponent = addComponent,
                onClickNext = {
                    onDismiss()
                },
                onClickPrevious = {
                    currentPage = LedgerOnboardingPage.DATE
                }
            )
        }
    }
}

 

 

난관 2. 기존 화면의 컴포넌트 위치 그대로 온보딩 화면에 띄워야 한다.

기존 화면, 장부 열람 기간 온보딩, 장부 내역 등록 온보딩

 

보다시피 날짜와 FAB 버튼이 기존 화면과 동일한 위치에서 띄워져야 한다.

 

첫 번째로 든 생각은

'기존 화면의 모든 Composable 을 복사 붙여넣기한 뒤, 불필요한 UI 만 alpha 값을 조절할까?' 였다.

 

하지만 그렇게 했을 때, 유지보수 비용이 걱정되었다.

기존 화면의 UI 가 변경될 때마다 온보딩 화면도 반영해줘야 했다.

그렇다고 기존 화면의 전체 Composable 함수를 그대로 사용하자니, side effect 가 너무 컸다.

 

 

두 번째로 든 생각은

'기존 화면에서 컴포넌트의 위치를 저장하고 이를 활용하자' 였다.

| 결과적으로 해당 방법을 통해 구현

 

그래서 다음과 같은 data class 를 정의하고,

기존 화면에서 해당 컴포넌트의 offset 과 size 를 저장한 뒤 해당 값을 온보딩 화면에 넘겨서 그려주면 가능하다 생각했다.

internal data class OnboardingComponentState(
    val offset: Offset = Offset.Zero,
    val size: IntSize = IntSize.Zero
)

 

다음과 같이 기존 화면의 컴포넌트에서 offset 과 size 를 측정한 뒤

MDSFloatingActionButton(
    modifier = Modifier
        .rotate(rotationAngle)
        .onGloballyPositioned { layoutCoordinates ->
            addFABState = OnboardingComponentState(
                offset = layoutCoordinates.localToRoot(Offset.Zero),
                size = layoutCoordinates.size
            )
        },

 

온보딩 화면의 인자값으로 전달한다.

@Composable
internal fun LedgerOnboarding(
    modifier: Modifier = Modifier,
    isStaff: Boolean,
    dateComponent: OnboardingComponentState,
    addComponent: OnboardingComponentState,
    onDismiss: () -> Unit
) {

 

 

온보딩 화면에선 전달 받은 인자값을 다음과 같이 사용할 수 있다.

MDSFloatingActionButton(
    modifier = Modifier
        .size(
            width = addComponent.size.width.pxToDp,
            height = addComponent.size.height.pxToDp
        )
        .offset {
            IntOffset(
                x = addComponent.offset.x.toInt(),
                y = addComponent.offset.y.toInt()
            )
        },

 

 

난관 2-2. Tooltip 의 위치

아래와 같이 컴포넌트 상.하엔 컴포넌트를 설명하는 tooltip 이 존재한다.

컴포넌트의 위치는 잡았다면, tooltip 의 위치는 어떻게 잡았을까??

 

왼쪽은 간단하다.

인자로 받은(기존 화면에서 구한) 컴포넌트의 위치를 Column 으로 지정하면 된다.

코드는 다음과 같다.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .offset {
            IntOffset(
                x = dateComponent.offset.x.toInt(),
                y = dateComponent.offset.y.toInt()
            )
        },
) {
    LedgerDefaultDateRow(
        startDate = currentDate.minusMonths(6),
        endDate = currentDate,
        onClickPeriod = {},
    )
    Spacer(modifier = Modifier.height(10.dp))
    LedgerOnboardingToolTip(
        modifier = Modifier.align(Alignment.CenterHorizontally),
        text = "장부 열람 기간을 설정할 수 있어요!",
        verticalArrowPosition = VerticalArrowPosition.TOP,
        horizontalArrowPosition = HorizontalArrowPosition.CENTER
    )
}

 

장부 내역 등록 툴팁

 

하지만 위 툴팁은? 컴포넌트 상단에 위치한다.

우리가 가진 정보는 FAB button 의 위치, 크기 정보이다.

툴팁이 상단에 위치하여, 위 날짜 설정 온보딩의 툴팁처럼 Column 으로 지정할 수가 없다.

 

여기서 해결 포인트는 툴팁의 크기와 FAB button 의 위치, 크기를 알고 있다면 툴팁의 위치도 정할 수 있다는 거다.

 

먼저 툴팁의 x 좌표부터 구하는 방법을 알아보자.

 

우리가 구해야할 정보는 초록색 선의 x 좌표이고,

우리는 알고 있는 정보는 다음과 같다.

  • 빨간색 선의 x 좌표(FAB button의 x 위치)
  • 파란색만큼의 거리(FAB button의 width)
  • 노란색만큼의 거리(Tooltip의 width)

활용한다면  빨강선 - 파란선 + 노란선 = 초록선

코드는 다음과 같다.

val tooltipX = addComponent.offset.x.toInt() + addComponent.size.width - placeable.width

 

이제 y좌표를 구해보자.

 

우리가 구해야할 정보는 초록색 선의 y 좌표이고,

우리는 알고 있는 정보는 다음과 같다.

  • 빨간색 선의 y 좌표(FAB button의 y 위치)
  • 파란색만큼의 거리(Tooltip의 height)
  • 노란색만큼의 거리(margin == 10.dp)

활용한다면  빨강선 - 파란선 - 노란선 = 초록선

코드는 다음과 같다.

val tooltipY = addComponent.offset.y.toInt() - placeable.height - 10.dp.roundToPx()

 

 

결과적으로 다음의 전체 코드로 tooltip 의 위치를 지정했다.

LedgerOnboardingToolTip(
    modifier = Modifier.layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)
        layout(placeable.width, placeable.height) {
            val tooltipX =
                addComponent.offset.x.toInt() + addComponent.size.width - placeable.width
            val tooltipY =
                addComponent.offset.y.toInt() - placeable.height - 10.dp.roundToPx()
            placeable.placeRelative(
                x = tooltipX,
                y = tooltipY
            )
        }
    }

 

 

 

이렇게 해서 기존 화면에 기반한 온보딩 화면을 구현할 수 있었고, 다음은 본 글의 내용이 포함된 PR 이다.

https://github.com/MONEYMONG/Android-Moneymong/pull/7

 

Feature/moneymong 374 장부 튜토리얼 by jhg3410 · Pull Request #7 · MONEYMONG/Android-Moneymong

요약 장부 튜토리얼(온보딩) 화면을 적용했습니다. 작업내용 장부 온보딩 화면 구현 (data, UI) 사용자마다 역할별로 한 번씩(운영진일 때 한번, 멤버일 때 한번) MyMongMarkdown 관련 코드에 작은 수정

github.com