내돈내산, Compose Focus

goddoro
WATCHA

--

안녕하세요 왓챠의 안드로이드 개발자 doro입니다. 왓챠는 올해 Android Compose를 도입해 UI 로직을 작성하는데 많은 코드 개선을 할 수 있었습니다.

Compose를 사용하여 깔끔한 UI 로직을 작성할 수 있었고, 관심사의 분리, UI Test 등 많은 편리함 또한 가져다 주었습니다. 하지만 모두 좋은 경험을 했던 것만은 아니었는데요.

TV는 포커스를 싣고

Android TV앱을 개발할 때, 가장 중요한 개념은 포커스(Focus)라고 생각합니다.

모바일은 터치로 내비게이션이 이루어지는 반면에 TV는 리모컨으로 제어가 됩니다. 즉 포커스에 의해 현재 어느 위젯이 선택됐는지 알 수 있죠. 그래서 TV앱을 개발할 때는 포커스와 정말 친해져야 합니다.

WATCHA Android TV 홈

기존에는 UI코드를 작성할 때 구글에서 제공하는 DPAD(리모컨)전용 라이브러리인 Leanback을 사용하여 간단하고 직관적인 내비게이션을 구현할 수 있었지만, Compose는 아직 Leanback 과 같은 만능 라이브러리가 존재하지 않았습니다.

FocusRequesterFocusManager 를 제외하고는 포커스 관련한 API들이 제공되지 않아서 대부분의 UI/UX를 손수 커스텀 클래스를 만들어서 사용해야만 했습니다.

이번 포스팅에서는 Compose에서 포커스를 이용할 때 직면했던 문제를 어떻게 해결했는지에 대해서 이야기 해보려고 합니다.

FocusableComposeView

가장 처음으로 겪었던 문제는 상호운용과 관련된 문제였습니다.

계정 정보로 포커스가 이동하지 않는 오류

위 영상 처럼, 메인 메뉴에서 설정탭을 선택할 경우 내부 컨텐츠쪽으로 포커스가 넘어오지 않는 문제가 있었습니다. 페이지가 호출될 때 설정계정정보 메뉴로 포커스가 이동을 해야하는데 말이죠.

이유는 간단했습니다. setContent메소드를 호출할 때 내부적으로 AndroidComposeView 클래스를 생성하게 되는데, 이 객체가 focusable 해서 생기는 문제였습니다.

설정탭 Focus Tree

AndroidComposeView자체를 not focusable하게 만들지는 못하므로 포커스 자체를 SettingScreen 까지 내려주어야만 하는 상황이었는데요. AbstractComposeView 상속한 FocusableComposeView 라는 커스텀 클래스를 만들어 해결할 수 있었습니다

FocusableComposeView 가 포커스를 가지고, 화면이 모두 만들어진 시점에 SettingScreenModifier 에 등록된 FocusRequesterrequestFocus 메소드를 호출하는 방식입니다.

안드로이드 OS에 따라서 onFocuChanged 콜백이 먼저 호출될 때도 있었고, Content 가 먼저 실행될 수도 있었기 때문에 LaunchedEffect 를 통해서 gainFocuskey 로 가지고 있게 구현하였습니다.

FocusableComposeView가 적용된 화면

VerticalGridLayout

설정페이지의 메뉴UI를 구현할 때도 문제가 있었습니다. 고객센터 공지사항 으로 들어갔다면 나올 때도 공지사항고객센터 위치로 나와야하지만, 공지사항계정 정보로 이동하고 있었습니다.

메뉴 UX의 오류

이유는 twoDimensionalSearch 라는 focusSearch 의 특별한 포커스 노드 찾는 방법 때문인데요. 단순히 위치상 가장 가까운 메뉴에게 다음 포커스를 전달해주고 있었습니다.

구글의 Leanback 에서 제공하는 VerticalGridView 라는 컴포넌트와 동작이 유사하기 때문에 GridLayout이라는 커스텀 클래스를 만들어서 해결하고자 했습니다.

GridLayout & GridFocus

GridLayoutgridFocusItem 은 내부적으로 CompositionLocalProvider 로 데이터 통신을 이루고 있고, GridLayout 은 가장 최근에 사용했던 focusRequesterfocusManager 에 저장하고 있었습니다.

그리고 포커스를 받을때마다 focusManagerrequestFocus 메소드를 호출해줌으로써 자식 컴포즈에게 포커스를 전달해줄수 있었습니다.

gridFocus로 선언된 아이템들은 GridLayout에게 자신의 FocusRequester 를 계속 올려줌으로써 focusManager 를 갱신해줄 수 있게 되는 것입니다. 위치를 기억시키는게 아니라 FocusRequester 값을 저장해주는 방식이죠.

GridLayout이 적용된 메뉴

좀 더 디테일한 UX도 해결해야 했습니다. 플레이어 화면에서는 컨트롤러에 진입시 정확히 재생 버튼에 포커스가 가야했는데요. 현재 GridLayout 은 항상 첫 번째 아이템에게만 포커스를 전달해 줄 수 있었기 때문에 가장 왼쪽 버튼인 나가기 버튼에 포커스가 가고 있었습니다.

GridLayout 을 라이브러리처럼 사용하고 싶었고 추상화 단계를 유지해야 했기 때문에, 어떤 버튼이 초기 포커스를 받는 버튼인가 와 관련된 정보만 넘겨주는 방향으로 기능을 추가했습니다.

@Composable
fun Modifier.gridFocus(isDefaultIndex: Boolean = false) = composed {
val focusRequester = remember { FocusRequester() }
val localFocusGridEvent = LocalGridFocusEvent.current
assert(localFocusGridEvent != null)
if (isDefaultIndex) {
remember {
localFocusGridEvent?.onFocusChanged(focusRequester)
}
}

...
}

이런식으로 gridFocusisDefaultIndex 라는 상태값을 전달해줄 수 있도록 파라미터를 추가했습니다. 해당 상태값이 true 라면 현재 아이템의 focusRequesterLocalCompositionProvider 를 통해서 GridLayout 으로 올려보낼 수 있게 되는 것이죠.

모두 false 일 경우에는 Column 또는 Row 의 포커스 정책에 의해서 가장 첫 번째 아이템에게 포커스가 이동할 것입니다.

defaultIndex가 변경된 GridLayout

나가기 재생(일시정지)처음부터보기자막선택 순서로 GridLayout 을 구성해서 컨트롤러 UI/UX를 만들 수 있었고, 재생버튼 gridFocusisDefaultIndextrue 로 할당해 유저가 컨트롤러를 사용할 때 초기 위치로 재생 버튼에 포커스를 가게 만들어주었습니다.

이는 플레이어 사용시에 가장 빈번하게 사용되는 버튼을 좀 더 편하게 사용할 수 있는 재생 경험을 가져다 줄 것입니다.

CenterFocusable

다음은 플레이어 화면에서 발생했던 문제입니다. 영상의 Seeking을 좀 더 직관적으로 하기 위해 썸네일 리스트를 제공해주는데요.

LazyRow의 FocusSearch 에러

focusSearch 정책인 twoDimensionalSearch때문에 화면 밖에 존재하는 위젯을 찾을수 없었고, 결과적으로 썸네일 이미지에게 포커스를 전달해주지 못하는 상황이 발생하고 있었습니다.

그래서 포커스가 이동함에 따라서 LazyListState에게 scrollToItem 까지도 같이 동작을 하게 해줘서 포커스의 이동과 리스트의 이동을 동시에 동작하게 해야만 했었는데요.

scroll과 focus를 동시에

visibleItemCount / 2 로 맨 앞 아이템부터 가운데 아이템까지의 값을 구할 수 있고 포커스는 index 에게주고 scrollToItemindex — center 로 이동해준다면 마치 가운데 아이템에 포커스가 있는채로 리스트의 UI가 변경되는 것을 알 수 있습니다.

구현은 아래와 같습니다.

itemsIndexed(playerThumbnails) { index, playerThumbnail ->
PlayerThumbnailItem(
modifier = Modifier.onFocusChanged {
coroutineScope.launch {
listState.scrollToItem(max(0, index - center)
}
},

thumbnail = playerThumbnail,
onClickThumbnail = onClickThumbnail
)
}

위의 코드에서 index 는 현재 포커스를 받은 아이템의 인덱스이고, index — center 위치로 scrollToItem 메소드를 호출해줌으로써 centerFocusable 기능을 구현할 수 있었습니다.

만약 scrollToItem 이외에 어떤 이벤트를 수행해야 한다면, 같은 coroutineScope 에서 실행해줘야 UI의 딜레이가 발생하지 않습니다.

스크롤을 동시에 넣어준 LazyRow

마치며

컴포즈와 포커스의 조합은 아직 많은 개발자들이 고민하지 않은 도메인이라 하나하나 컴포넌트를 개발해야하는 점이 불편하긴 했지만 그 덕분에 공식 문서와 친해지게 되고 라이브러리 내부 코드를 보며 컴포즈에 대한 이해도를 더 높일 수 있었습니다.

끝으로 같이 고민했던 안드로이드 개발팀 peter에게도 감사의 말씀을 올리며 포스팅을 마무리하겠습니다.

감사합니다.

--

--

goddoro
WATCHA
Writer for

TVING에서 안드로이드를 개발하고 있습니다