안녕하세요 왓챠의 안드로이드 개발자 doro입니다. 왓챠는 올해 Android Compose를 도입해 UI 로직을 작성하는데 많은 코드 개선을 할 수 있었습니다.
Compose를 사용하여 깔끔한 UI 로직을 작성할 수 있었고, 관심사의 분리, UI Test 등 많은 편리함 또한 가져다 주었습니다. 하지만 모두 좋은 경험을 했던 것만은 아니었는데요.
TV는 포커스를 싣고
Android TV앱을 개발할 때, 가장 중요한 개념은 포커스(Focus)라고 생각합니다.
모바일은 터치로 내비게이션이 이루어지는 반면에 TV는 리모컨으로 제어가 됩니다. 즉 포커스에 의해 현재 어느 위젯이 선택됐는지 알 수 있죠. 그래서 TV앱을 개발할 때는 포커스와 정말 친해져야 합니다.
기존에는 UI코드를 작성할 때 구글에서 제공하는 DPAD(리모컨)전용 라이브러리인 Leanback
을 사용하여 간단하고 직관적인 내비게이션을 구현할 수 있었지만, Compose는 아직 Leanback
과 같은 만능 라이브러리가 존재하지 않았습니다.
FocusRequester
와 FocusManager
를 제외하고는 포커스 관련한 API들이 제공되지 않아서 대부분의 UI/UX를 손수 커스텀 클래스를 만들어서 사용해야만 했습니다.
이번 포스팅에서는 Compose에서 포커스를 이용할 때 직면했던 문제를 어떻게 해결했는지에 대해서 이야기 해보려고 합니다.
FocusableComposeView
가장 처음으로 겪었던 문제는 상호운용과 관련된 문제였습니다.
위 영상 처럼, 메인 메뉴에서 설정탭을 선택할 경우 내부 컨텐츠쪽으로 포커스가 넘어오지 않는 문제가 있었습니다. 페이지가 호출될 때 설정 — 계정정보 메뉴로 포커스가 이동을 해야하는데 말이죠.
이유는 간단했습니다. setContent
메소드를 호출할 때 내부적으로 AndroidComposeView
클래스를 생성하게 되는데, 이 객체가 focusable
해서 생기는 문제였습니다.
AndroidComposeView
자체를 not focusable
하게 만들지는 못하므로 포커스 자체를 SettingScreen
까지 내려주어야만 하는 상황이었는데요. AbstractComposeView
를 상속한 FocusableComposeView 라는 커스텀 클래스를 만들어 해결할 수 있었습니다
FocusableComposeView
가 포커스를 가지고, 화면이 모두 만들어진 시점에 SettingScreen
의 Modifier
에 등록된 FocusRequester
가 requestFocus
메소드를 호출하는 방식입니다.
안드로이드 OS에 따라서 onFocuChanged
콜백이 먼저 호출될 때도 있었고, Content
가 먼저 실행될 수도 있었기 때문에 LaunchedEffect
를 통해서 gainFocus
를 key
로 가지고 있게 구현하였습니다.
VerticalGridLayout
설정페이지의 메뉴UI를 구현할 때도 문제가 있었습니다. 고객센터 → 공지사항 으로 들어갔다면 나올 때도 공지사항 → 고객센터 위치로 나와야하지만, 공지사항 → 계정 정보로 이동하고 있었습니다.
이유는 twoDimensionalSearch
라는 focusSearch
의 특별한 포커스 노드 찾는 방법 때문인데요. 단순히 위치상 가장 가까운 메뉴에게 다음 포커스를 전달해주고 있었습니다.
구글의 Leanback
에서 제공하는 VerticalGridView
라는 컴포넌트와 동작이 유사하기 때문에 GridLayout이라는 커스텀 클래스를 만들어서 해결하고자 했습니다.
GridLayout
과 gridFocusItem
은 내부적으로 CompositionLocalProvider
로 데이터 통신을 이루고 있고, GridLayout
은 가장 최근에 사용했던 focusRequester
를 focusManager
에 저장하고 있었습니다.
그리고 포커스를 받을때마다 focusManager
가 requestFocus
메소드를 호출해줌으로써 자식 컴포즈에게 포커스를 전달해줄수 있었습니다.
gridFocus
로 선언된 아이템들은 GridLayout
에게 자신의 FocusRequester
를 계속 올려줌으로써 focusManager
를 갱신해줄 수 있게 되는 것입니다. 위치를 기억시키는게 아니라 FocusRequester
값을 저장해주는 방식이죠.
좀 더 디테일한 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)
}
}
...
}
이런식으로 gridFocus
에 isDefaultIndex
라는 상태값을 전달해줄 수 있도록 파라미터를 추가했습니다. 해당 상태값이 true
라면 현재 아이템의 focusRequester
를 LocalCompositionProvider
를 통해서 GridLayout
으로 올려보낼 수 있게 되는 것이죠.
모두 false
일 경우에는 Column
또는 Row
의 포커스 정책에 의해서 가장 첫 번째 아이템에게 포커스가 이동할 것입니다.
나가기 — 재생(일시정지) — 처음부터보기 — 자막선택 순서로 GridLayout
을 구성해서 컨트롤러 UI/UX를 만들 수 있었고, 재생버튼 gridFocus
에 isDefaultIndex
를 true
로 할당해 유저가 컨트롤러를 사용할 때 초기 위치로 재생 버튼에 포커스를 가게 만들어주었습니다.
이는 플레이어 사용시에 가장 빈번하게 사용되는 버튼을 좀 더 편하게 사용할 수 있는 재생 경험을 가져다 줄 것입니다.
CenterFocusable
다음은 플레이어 화면에서 발생했던 문제입니다. 영상의 Seeking을 좀 더 직관적으로 하기 위해 썸네일 리스트를 제공해주는데요.
focusSearch
정책인 twoDimensionalSearch
때문에 화면 밖에 존재하는 위젯을 찾을수 없었고, 결과적으로 썸네일 이미지에게 포커스를 전달해주지 못하는 상황이 발생하고 있었습니다.
그래서 포커스가 이동함에 따라서 LazyListState
에게 scrollToItem
까지도 같이 동작을 하게 해줘서 포커스의 이동과 리스트의 이동을 동시에 동작하게 해야만 했었는데요.
visibleItemCount / 2
로 맨 앞 아이템부터 가운데 아이템까지의 값을 구할 수 있고 포커스는 index
에게주고 scrollToItem
은 index — 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의 딜레이가 발생하지 않습니다.
마치며
컴포즈와 포커스의 조합은 아직 많은 개발자들이 고민하지 않은 도메인이라 하나하나 컴포넌트를 개발해야하는 점이 불편하긴 했지만 그 덕분에 공식 문서와 친해지게 되고 라이브러리 내부 코드를 보며 컴포즈에 대한 이해도를 더 높일 수 있었습니다.
끝으로 같이 고민했던 안드로이드 개발팀 peter에게도 감사의 말씀을 올리며 포스팅을 마무리하겠습니다.
감사합니다.