Compose의 키보드 포커스 관리

1. 소개

사용자는 일반적으로 태블릿 및 ChromeOS 기기와 같은 대형 화면 기기에서 하드웨어 키보드를 사용하여 앱과 상호작용할 수 있지만 XR 기기에서도 사용할 수 있습니다. 사용자가 터치 스크린을 사용할 때와 마찬가지로 하드웨어 키보드로도 앱을 효과적으로 탐색할 수 있어야 합니다. 또한 터치 입력이 없고 대신 D패드나 로터리 인코더를 사용하는 TV 및 자동차 디스플레이용 앱을 설계할 때는 유사한 키보드 탐색 원칙을 적용해야 합니다.

Compose를 사용하면 하드웨어 키보드, D-패드, 로터리 인코더의 입력을 통합된 방식으로 처리할 수 있습니다. 이러한 입력 방법의 우수한 사용자 환경을 위한 핵심 원칙은 사용자가 상호작용하려는 대화형 구성요소로 키보드 포커스를 직관적이고 일관되게 이동할 수 있다는 것입니다.

이 Codelab에서는 다음을 학습합니다.

  • 직관적이고 일관된 탐색을 위해 일반적인 키보드 포커스 관리 패턴을 구현하는 방법
  • 키보드 포커스 이동이 예상대로 작동하는지 테스트하는 방법

기본 요건

  • Compose를 사용하여 앱을 빌드한 경험
  • 람다 및 코루틴을 비롯한 Kotlin 관련 기본 지식

빌드할 항목

다음과 같은 일반적인 키보드 포커스 관리 패턴을 구현합니다.

  • 키보드 포커스 이동: 시작부터 끝까지, 위에서 아래로 Z자 패턴
  • 논리적 초기 포커스: 사용자가 상호작용할 가능성이 높은 UI 요소에 포커스를 설정합니다.
  • 포커스 복원: 사용자가 이전에 상호작용한 UI 요소로 포커스를 이동합니다.

학습할 내용

  • Compose의 포커스 관리 기본사항
  • UI 요소를 포커스 타겟으로 만드는 방법
  • UI 요소를 이동하도록 포커스를 요청하는 방법
  • UI 요소 그룹에서 특정 UI 요소로 키보드 포커스를 이동하는 방법

필요한 항목

  • Android 스튜디오 Ladybug 이상 버전
  • 샘플 앱을 실행할 수 있는 기기:
  • 하드웨어 키보드가 있는 대형 화면 기기
  • 크기 조절 가능한 에뮬레이터와 같은 대형 화면 기기용 Android Virtual Device

2. 설정

  1. large-screen-codelabs GitHub 저장소를 클론합니다.
git clone https://github.com/android/large-screen-codelabs

또는 large-screen-codelabs ZIP 파일을 다운로드하고 보관 취소해도 됩니다.

  1. focus-management-in-compose 폴더로 이동합니다.
  2. Android 스튜디오에서 프로젝트를 엽니다. focus-management-in-compose 폴더에는 하나의 프로젝트가 포함됩니다.
  3. Android 태블릿, 폴더블 기기 또는 하드웨어 키보드가 있는 ChromeOS 기기가 없는 경우 Android 스튜디오에서 기기 관리도구r를 연 다음 휴대전화 카테고리에 크기 변경이 가능한 기기를 만듭니다.

Android 스튜디오의 기기 관리도구에는 휴대전화 카테고리의 사용 가능한 가상 기기 목록이 표시됩니다. 크기 조절 가능한 에뮬레이터가 이 카테고리에 속합니다.그림 1. Android 스튜디오에서 크기 조절 가능한 에뮬레이터 구성

3. 시작 코드 살펴보기

프로젝트에는 두 개의 모듈이 있습니다.

  • start: 프로젝트의 시작 코드가 포함되어 있습니다. 이 코드를 변경하여 Codelab을 완료하게 됩니다.
  • solution: 이 Codelab의 완성된 코드가 포함되어 있습니다.

샘플 앱은 다음 세 가지 탭으로 구성됩니다.

  • 포커스 타겟
  • 포커스 순회 순서
  • 포커스 그룹

포커스 타겟 탭은 앱이 실행될 때 표시됩니다.

샘플 앱의 첫 번째 뷰입니다. 탭이 3개 있으며 포커스 타겟 탭(첫 번째 탭)이 선택되어 있습니다. 탭에 열에 배치된 카드 3개가 표시됩니다.

그림 2. 앱이 실행되면 포커스 타겟 탭이 표시됩니다.

ui 패키지에는 상호작용할 다음 UI 코드가 포함되어 있습니다.

4. 포커스 타겟

포커스 타겟은 키보드 포커스가 이동할 수 있는 UI 요소입니다. 사용자는 Tab 키 또는 방향 (화살표) 키를 사용하여 키보드 포커스를 이동할 수 있습니다.

  • Tab 키: 포커스가 다음 포커스 타겟 또는 이전 포커스 타겟으로 1차원적으로 이동합니다.
  • 방향 키: 포커스를 위, 아래, 왼쪽, 오른쪽 등 2차원적으로 이동할 수 있습니다.

탭은 포커스 타겟입니다. 샘플 앱에서는 탭에 포커스가 있을 때 탭의 배경이 시각적으로 업데이트됩니다.

GIF 애니메이션 파일은 키보드 포커스가 UI 요소 간에 이동하는 방식을 보여줍니다. 탭 3개를 이동한 후 첫 번째 카드에 포커스가 설정됩니다.

그림 3. 포커스가 포커스 타겟으로 이동하면 구성요소 배경이 변경됩니다.

상호작용이 가능한 UI 요소는 기본적으로 포커스 타겟입니다.

대화형 구성요소는 기본적으로 포커스 타겟입니다. 즉, 사용자가 탭할 수 있는 경우 UI 요소는 포커스 타겟입니다.

샘플 앱의 포커스 타겟 탭에는 카드가 3개 있습니다. 첫 번째 카드세 번째 카드는 포커스 타겟이고 두 번째 카드는 포커스 타겟이 아닙니다. 사용자가 Tab 키를 사용하여 첫 번째 카드에서 포커스를 이동하면 세 번째 카드의 배경이 업데이트됩니다.

GIF 애니메이션은 포커스 타겟 탭에서 초기 키보드 포커스 이동을 보여줍니다. 사용자가 첫 번째 카드에서 Tab 키를 누르면 두 번째 카드를 건너뛰고 첫 번째 카드에서 세 번째 카드로 이동합니다.

그림 4. 앱 포커스 타겟은 두 번째 카드를 제외합니다.

두 번째 카드를 포커스 타겟으로 수정

두 번째 카드를 상호작용이 가능한 UI 요소로 변경하여 포커스 타겟으로 만들 수 있습니다. 가장 쉬운 방법은 다음과 같이 clickable 수정자를 사용하는 것입니다.

  1. tabs 패키지에서 FocusTargetTab.kt 열기
  2. 다음과 같이 clickable 수정자로 SecondCard 컴포저블을 수정합니다.
@Composable
fun FocusTargetTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier.width(240.dp)
        )
        SecondCard(
            modifier = Modifier
                .width(240.dp)
                .clickable(onClick = onClick)
        )
        ThirdCard(
            onClick = onClick,
            modifier = Modifier.width(240.dp)
        )
    }
}

실행하기

이제 사용자는 첫 번째 카드세 번째 카드 외에도 두 번째 카드로 포커스를 이동할 수 있습니다. 포커스 타겟 탭에서 시도해 볼 수 있습니다. Tab 키를 사용하여 포커스를 첫 번째 카드에서 두 번째 카드로 이동할 수 있는지 확인합니다.

GIF 애니메이션은 수정 후 키보드 포커스 이동을 보여줍니다. 사용자가 첫 번째 카드에서 Tab 키를 누르면 첫 번째 카드에서 이동합니다.

그림 5. Tab 키를 사용하여 포커스를 첫 번째 카드에서 두 번째 카드로 이동합니다.

5. Z자 패턴의 포커스 순회

사용자는 왼쪽에서 오른쪽 언어 설정에서 키보드 포커스가 왼쪽에서 오른쪽으로, 위에서 아래로 이동할 것으로 기대합니다. 이 포커스 순회 순서를 z자 패턴이라고 합니다.

그러나 Compose는 Tab 키의 다음 포커스 타겟을 결정할 때 레이아웃을 무시하고 대신 컴포저블 함수 호출 순서에 따라 일차원 포커스 순회를 사용합니다.

일차원 포커스 순회

일차원 포커스 순회 순서는 앱 레이아웃이 아닌 컴포저블 함수 호출 순서에서 가져옵니다.

샘플 앱에서 포커스는 포커스 순회 순서 탭에서 다음 순서로 이동합니다.

  1. 첫 번째 카드
  2. 네 번째 카드
  3. 세 번째 카드
  4. 두 번째 카드

GIF 애니메이션은 키보드 포커스가 사용자의 예상과 다르게 움직이는 것을 보여줍니다.  첫 번째 카드에서 세 번째 카드로, 세 번째 카드에서 네 번째 카드로, 네 번째 카드에서 두 번째 카드로 이동합니다. 사용자의 기대에 미치지 못할 수 있습니다.

그림 6. 포커스 순회는 컴포저블 함수의 순서를 따릅니다.

FocusTraversalOrderTab 함수는 샘플 앱의 포커스 전환 탭을 구현합니다. 이 함수는 카드의 컴포저블 함수(FirstCard, FourthCard, ThirdCard, SecondCard)를 순서대로 호출합니다.

@Composable
fun FocusTraversalOrderTab(
    modifier: Modifier = Modifier
) {
    Row(
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier
    ) {
        Column(
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            FirstCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier
                    .width(240.dp)
                    .offset(x = 256.dp)
            )
            ThirdCard(
                onClick = onClick,
                modifier = Modifier
                    .width(240.dp)
                    .offset(y = (-151).dp)
            )
        }
        SecondCard(
            modifier = Modifier.width(240.dp)
        )
    }
}

z자 패턴의 포커스 이동

다음 단계에 따라 샘플 앱의 포커스 순회 순서 탭에 Z자 포커스 이동을 통합할 수 있습니다.

  1. tabs.FocusTraversalOrderTab.kt 열기
  2. ThirdCardFourthCard 컴포저블에서 오프셋 수정자를 삭제합니다.
  3. 탭의 레이아웃을 현재의 2열 1행에서 1열 2행으로 변경합니다.
  4. FirstCardSecondCard 컴포저블을 첫 번째 행으로 이동합니다.
  5. ThirdCardFourthCard 컴포저블을 두 번째 행으로 이동합니다.

수정된 코드는 다음과 같습니다.

@Composable
fun FocusTraversalOrderTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            FirstCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp),
            )
            SecondCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp)
            )
        }
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            ThirdCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier.width(240.dp)
            )
        }
    }
}

실행하기

이제 사용자는 z자 패턴으로 포커스를 오른쪽에서 왼쪽, 위에서 아래로 이동할 수 있습니다. 포커스 순회 순서 탭에서 Tab 키를 사용하여 포커스가 다음 순서로 이동하는지 확인해 보세요.

  1. 첫 번째 카드
  2. 두 번째 카드
  3. 세 번째 카드
  4. 네 번째 카드

GIF 애니메이션은 수정 후 키보드 포커스가 어떻게 이동하는지 보여줍니다. z 순서에 따라 왼쪽에서 오른쪽, 위에서 아래로 이동합니다.

그림 7. Z자 패턴의 포커스 순회

6. focusGroup

포커스 그룹 탭에서 right 방향 키를 사용하여 포커스가 첫 번째 카드에서 세 번째 카드로 이동합니다. 두 카드가 나란히 표시되지 않으므로 사용자에게 약간 혼란스러울 수 있습니다.

GIF 애니메이션은 오른쪽 방향 키를 사용하여 키보드 포커스가 첫 번째 카드에서 세 번째 카드로 이동하는 것을 보여줍니다. 이 두 카드는 서로 다른 행에 배치됩니다.

그림 8. 첫 번째 카드에서 세 번째 카드로 포커스가 예기치 않게 이동합니다.

2차원 포커스 순회가 레이아웃 정보를 참고함

방향 키를 누르면 2차원 포커스 순회가 트리거됩니다. 이는 사용자가 D패드를 사용하여 앱과 상호작용할 때 TV에서 일반적으로 사용되는 포커스 순회입니다. 키보드 화살표 키를 누르면 D패드로 탐색하는 것처럼 2차원 포커스 순회도 트리거됩니다.

2차원 포커스 순회에서 시스템은 UI 요소의 기하학적 정보를 참고하고 포커스를 이동할 포커스 타겟을 결정합니다. 예를 들어 down 방향 키를 사용하면 포커스 타겟 탭에서 첫 번째 카드로 포커스가 이동하고 위 방향 키를 누르면 포커스 타겟 탭으로 포커스가 이동합니다.

GIF는 포커스가 아래 방향 키를 사용하여 포커스 타겟 탭에서 첫 번째 카드로 이동한 다음 위 방향 키를 사용하여 탭으로 돌아가는 것을 보여줍니다. 이 두 포커스 타겟은 세로로 가장 가깝습니다.

그림 9. 아래쪽 및 위쪽 방향 키를 사용한 포커스 순회

Tab 키를 사용한 1차원 포커스 순회와 달리 2차원 포커스 순회는 래핑되지 않습니다. 예를 들어 두 번째 카드에 포커스가 있으면 사용자는 아래쪽 키를 사용하여 포커스를 이동할 수 없습니다.

GIF는 사용자가 아래 방향 키를 누르더라도 포커스 타겟이 카드 아래에 배치되지 않아 포커스가 두 번째 카드에 유지되는 것을 보여줍니다.

그림 10. 두 번째 카드에 포커스가 있을 때 아래 방향 키를 누르면 포커스가 이동하지 않습니다.

포커스 타겟이 동일한 수준에 있음

다음 코드는 위에 설명된 화면을 구현합니다. 포커스 타겟은 FirstCard, SecondCard, ThirdCard, FourthCard의 4가지입니다. 이 네 개의 포커스 타겟은 동일한 수준이며 ThirdCard는 레이아웃에서 FirstCard 오른쪽에 있는 첫 번째 항목입니다. 따라서 right 방향 키를 사용하면 포커스가 첫 번째 카드에서 세 번째 카드로 이동합니다.

@Composable
fun FocusGroupTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier,
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier.width(208.dp)
        )
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            SecondCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            ThirdCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
        }
    }
}

focusGroup 수정자로 포커스 타겟 그룹화

혼동을 야기하는 포커스 이동을 변경하려면 다음 단계를 따르세요.

  1. tabs.FocusGroup.kt 열기
  2. FocusGroupTab 컴포저블 함수에서 focusGroup 수정자로 Column 컴포저블 함수를 수정합니다.

업데이트된 코드는 다음과 같습니다.

@Composable
fun FocusGroupTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier,
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier.width(208.dp)
        )
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            modifier = Modifier.focusGroup(),
        ) {
            SecondCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            ThirdCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
        }
    }
}

focusGroup 수정자는 수정된 구성요소 내의 포커스 타겟으로 구성된 포커스 그룹을 만듭니다. 포커스 그룹의 포커스 타겟과 포커스 그룹 외부의 포커스 타겟이 서로 다른 수준에 있으며 FirstCard 컴포저블의 오른쪽에 포커스 타겟이 배치되어 있지 않습니다. 따라서 right 방향 키를 사용하여 첫 번째 카드에서 다른 카드로 포커스가 이동하지 않습니다.

실행하기

이제 샘플 앱의 포커스 그룹 탭에서 right 방향 키를 사용해도 포커스가 첫 번째 카드에서 세 번째 카드로 이동하지 않습니다.

7. 포커스 요청

사용자가 키보드나 D-패드를 사용하여 상호작용할 임의의 UI 요소를 선택할 수 없습니다. 사용자가 요소와 상호작용하기 전에 키보드 포커스를 대화형 구성요소로 이동해야 합니다.

예를 들어 사용자가 카드와 상호작용하기 전에 포커스를 포커스 타겟 탭에서 첫 번째 카드로 이동해야 합니다. 초기 포커스를 논리적으로 설정하여 사용자의 기본 작업을 시작하는 작업 수를 줄일 수 있습니다.

GIF 애니메이션은 사용자가 탭을 선택한 후 Tab 키를 세 번 눌러 키보드 포커스를 탭의 첫 번째 카드로 이동해야 함을 보여줍니다.

그림 11. Tab 키를 세 번 누르면 포커스가 첫 번째 카드로 이동합니다.

FocusRequester로 포커스 요청

FocusRequester를 사용하여 UI 요소를 이동하도록 포커스를 요청할 수 있습니다. FocusRequester 객체는 requestFocus() 메서드를 호출하기 전에 UI 요소와 연결되어야 합니다.

초기 포커스를 첫 번째 카드로 설정

다음 단계에 따라 초기 포커스를 첫 번째 카드로 설정할 수 있습니다.

  1. tabs.FocusTarget.kt 열기
  2. FocusTargetTab 컴포저블 함수에서 firstCard 값을 선언하고 remember 함수에서 반환된 FocusRequester 객체로 값을 초기화합니다.
  3. focusRequester 수정자로 FirstCard 컴포저블 함수를 수정합니다.
  4. firstCard 값을 focusRequester 수정자의 인수로 지정합니다.
  5. Unit 값으로 LaunchedEffect 컴포저블 함수를 호출하고 LaunchedEffect 컴포저블 함수에 전달된 람다에서 firstCard 값에 대해 requestFocus() 메서드를 호출합니다.

FocusRequester 객체가 두 번째 및 세 번째 단계에서 생성되고 UI 요소와 연결됩니다. 다섯 번째 단계에서 FocusdTargetTab 컴포저블이 처음 컴포지션될 때 연결된 UI 요소로 포커스를 이동하도록 요청됩니다.

업데이트된 코드는 다음과 같습니다.

@Composable
fun FocusTargetTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    val firstCard = remember { FocusRequester() }

    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier
                .width(240.dp)
                .focusRequester(focusRequester = firstCard)
        )
        SecondCard(
            modifier = Modifier
                .width(240.dp)
                .clickable(onClick = onClick)
        )
        ThirdCard(
            onClick = onClick,
            modifier = Modifier.width(240.dp)
        )
    }

    LaunchedEffect(Unit) {
        firstCard.requestFocus()
    }
}

실행하기

이제 탭을 선택하면 키보드 포커스가 포커스 타겟 탭의 첫 번째 카드로 이동합니다. 탭을 전환하여 사용해 볼 수 있습니다. 또한 앱이 실행되면 첫 번째 카드가 선택됩니다.

GIF 애니메이션은 사용자가 포커스 타겟 탭을 선택하면 키보드 포커스가 자동으로 첫 번째 카드로 이동함을 보여줍니다.

그림 12. 포커스 타겟 탭을 선택하면 포커스가 첫 번째 카드로 이동합니다.

8. 선택한 탭으로 포커스 이동

키보드 포커스가 포커스 그룹에 진입할 때 포커스 타겟을 지정할 수 있습니다. 예를 들어 사용자가 포커스를 탭 행으로 이동할 때 포커스를 선택한 탭으로 이동할 수 있습니다.

다음 단계에 따라 이 동작을 구현할 수 있습니다.

  1. App.kt를 엽니다.
  2. App 컴포저블 함수에서 focusRequesters 값을 선언합니다.
  3. FocusRequester 객체 목록을 반환하는 remember 함수의 반환 값으로 focusRequesters 값을 초기화합니다. 반환된 목록의 길이는 Screens.entries의 길이와 같아야 합니다.
  4. focusRequester 수정자를 사용하여 Tab 컴포저블을 수정하여 focusRequester 값의 각 FocusRequester 객체를 Tab 컴포저블과 연결합니다.
  5. focusProperties 수정자와 focusGroup 수정자를 사용하여 PrimaryTabRow 컴포저블을 수정합니다.
  6. 람다를 focusProperties 수정자에 전달하고 enter 속성을 다른 람다와 연결합니다.
  7. enter 속성과 연결된 람다에서 focusRequesters 값의 selectedTabIndex 값으로 색인이 생성된 FocusRequester를 반환합니다.

수정된 코드는 다음과 같습니다.

@Composable
fun App(
    modifier: Modifier = Modifier,
) {
    val context = LocalContext.current

    var selectedScreen by rememberSaveable { mutableStateOf(Screen.FocusTarget) }
    val selectedTabIndex = Screen.entries.indexOf(selectedScreen)
    val focusRequesters = remember {
        List(Screen.entries.size) { FocusRequester() }
    }

    Column(modifier = modifier) {
        PrimaryTabRow(
            selectedTabIndex = selectedTabIndex,
            modifier = Modifier
                .focusProperties {
                    enter = {
                        focusRequesters[selectedTabIndex]
                    }
                }
                .focusGroup()
        ) {
            Screen.entries.forEachIndexed { index, screen ->
                Tab(
                    selected = screen == selectedScreen,
                    onClick = { selectedScreen = screen },
                    text = { Text(stringResource(screen.title)) },
                    modifier = Modifier.focusRequester(focusRequester = focusRequesters[index])
                )
            }
        }
        when (selectedScreen) {
            Screen.FocusTarget -> {
                FocusTargetTab(
                    onClick = context::onCardClicked,
                    modifier = Modifier.padding(32.dp),
                )
            }

            Screen.FocusTraversalOrder -> {
                FocusTraversalOrderTab(
                    onClick = context::onCardClicked,
                    modifier = Modifier.padding(32.dp)
                )
            }

            Screen.FocusRestoration -> {
                FocusGroupTab(
                    onClick = context::onCardClicked,
                    modifier = Modifier.padding(32.dp)
                )
            }
        }
    }
}

focusProperties 수정자를 사용하여 포커스 이동을 제어할 수 있습니다. 수정자에 전달된 람다에서 수정된 UI 요소에 포커스가 있을 때 사용자가 Tab 키 또는 방향 키를 누르면 시스템에서 포커스 타겟을 선택할 때 참고되는 FocusProperties를 수정합니다.

enter 속성을 설정하면 시스템은 속성에 설정된 람다를 평가하고 평가된 람다에서 반환된 FocusRequester 객체와 연결된 UI 요소로 이동합니다.

실행하기

이제 사용자가 포커스를 탭 행으로 이동하면 키보드 포커스가 선택된 탭으로 이동합니다. 다음 단계에 따라 시도해 보세요.

  1. 앱 실행
  2. 포커스 그룹 탭을 선택합니다.
  3. down 방향 키를 사용하여 포커스를 첫 번째 카드로 이동합니다.
  4. up 방향 키를 사용하여 포커스를 이동합니다.

그림 13. 포커스가 선택한 탭으로 이동합니다.

9. 포커스 복원

사용자는 작업이 중단될 때 쉽게 다시 시작할 수 있기를 기대합니다. 포커스 복원은 중단된 작업의 복구를 지원합니다. 포커스 복원은 키보드 포커스를 이전에 선택한 UI 요소로 이동합니다.

포커스 복원의 일반적인 사용 사례는 동영상 스트리밍 앱의 홈 화면입니다. 카테고리의 영화나 TV 프로그램의 에피소드와 같은 동영상 콘텐츠 목록이 화면에 여러 개 표시됩니다. 사용자가 목록을 둘러보고 흥미로운 콘텐츠를 찾습니다. 사용자가 이전에 살펴본 목록으로 돌아가서 계속 탐색하는 경우가 있습니다. 포커스 복원을 사용하면 사용자가 목록에서 마지막으로 본 항목으로 키보드 포커스를 이동하지 않고도 탐색을 계속할 수 있습니다.

focusRestorer 수정자가 포커스 그룹에 포커스를 복원합니다.

focusRestorer 수정자를 사용하여 포커스 그룹에 포커스를 저장하고 복원합니다. 포커스가 포커스 그룹을 벗어나면 포커스는 이전에 포커스가 있었던 항목에 대한 참조를 저장합니다. 그런 다음 포커스가 포커스 그룹에 다시 들어가면 포커스가 이전에 포커스가 있던 항목으로 복원됩니다.

포커스 그룹 탭과 포커스 복원 통합

샘플 앱의 포커스 그룹 탭에는 두 번째 카드, 세 번째 카드, 네 번째 카드가 포함된 행이 있습니다.

GIF 애니메이션은 이전에 세 번째 카드에 포커스가 있었더라도 키보드 포커스가 첫 번째 카드에서 두 번째 카드로 이동하는 것을 보여줍니다.

그림 14. 두 번째 카드, 세 번째 카드, 네 번째 카드가 포함된 포커스 그룹

다음 단계에 따라 행에 포커스 복원을 통합할 수 있습니다.

  1. tab.FocusGroupTab.kt 열기
  2. focusRestorer 수정자로 FocusGroupTab 컴포저블의 Row 컴포저블을 수정합니다. 이 수정자는 focusGroup 수정자 앞에 호출해야 합니다.

수정된 코드는 다음과 같습니다.

@Composable
fun FocusGroupTab(
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = modifier,
    ) {
        FirstCard(
            onClick = onClick,
            modifier = Modifier.width(208.dp)
        )
        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            modifier = Modifier
                .focusRestorer()
                .focusGroup(),
        ) {
            SecondCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            ThirdCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
            FourthCard(
                onClick = onClick,
                modifier = Modifier.width(208.dp)
            )
        }
    }
}

실행하기

이제 포커스 그룹 탭의 행에 포커스가 복원됩니다. 다음 단계에 따라 사용해 보세요.

  1. 포커스 그룹 탭을 선택합니다.
  2. 포커스를 첫 번째 카드로 이동합니다.
  3. Tab 키를 사용하여 포커스를 네 번째 카드로 이동합니다.
  4. up 방향 키를 사용하여 첫 번째 카드로 포커스를 이동합니다.
  5. Tab 키를 누릅니다.

focusRestorer 수정자가 카드의 참조를 저장하고 키보드 포커스가 행에 설정된 포커스 그룹으로 이동하면 포커스가 복원되므로 포커스가 네 번째 카드로 이동합니다.

GIF 애니메이션은 키보드 포커스가 해당 행으로 다시 들어갈 때 키보드 포커스가 행에서 이전에 선택한 카드로 이동하는 것을 보여줍니다.

그림 15. 위쪽 방향 키 누르기 후 Tab 키 누르기를 하면 포커스가 네 번째 카드로 돌아갑니다.

10. 테스트 작성

구현된 키보드 포커스 관리는 테스트로 테스트할 수 있습니다. Compose는 UI 요소에 포커스가 있는지 테스트하고 UI 구성요소에서 키 누르기를 실행하는 API를 제공합니다. 자세한 내용은 Jetpack Compose에서 테스트 Codelab을 참고하세요.

포커스 타겟 탭 테스트

이전 섹션에서 FocusTargetTab 컴포저블 함수를 수정하여 두 번째 카드를 포커스 타겟으로 설정했습니다. 이전 섹션에서 수동으로 실행한 구현 테스트를 작성합니다. 테스트는 다음 단계에 따라 작성할 수 있습니다.

  1. FocusTargetTabTest.kt를 엽니다. 다음 단계에서 testSecondCardIsFocusTarget 함수를 수정합니다.
  2. 첫 번째 카드SemanticsNodeInteraction 객체에서 requestFocus 메서드를 호출하여 포커스가 첫 번째 카드로 이동하도록 요청합니다.
  3. assertIsFocused() 메서드를 사용하여 첫 번째 카드에 포커스가 있는지 확인합니다.
  4. performKeyInput 메서드에 전달된 람다 내에서 Key.Tab 값으로 pressKey 메서드를 호출하여 Tab 키 누르기를 실행합니다.
  5. 두 번째 카드SemanticsNodeInteraction 객체에서 assertIsFocused() 메서드를 호출하여 키보드 포커스가 두 번째 카드로 이동하는지 테스트합니다.

업데이트된 코드는 다음과 같습니다.

@OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class)
@Test
fun testSecondCardIsFocusTarget() {
    composeTestRule.setContent {
        LocalInputModeManager
            .current
            .requestInputMode(InputMode.Keyboard)
        FocusTargetTab(onClick = {})
    }
    val context = InstrumentationRegistry.getInstrumentation().targetContext

    // Ensure the 1st card is focused
    composeTestRule
        .onNodeWithText(context.getString(R.string.first_card))
        .requestFocus()
        .performKeyInput { pressKey(Key.Tab) }

    // Test if focus moves to the 2nd card from the 1st card with Tab key
    composeTestRule
        .onNodeWithText(context.getString(R.string.second_card))
        .assertIsFocused()
}

실행하기

FocusTargetTest 클래스 선언 왼쪽에 표시된 삼각형 아이콘을 클릭하여 테스트를 실행할 수 있습니다. 자세한 내용은 Android 스튜디오에서 테스트테스트 실행 섹션을 참고하세요.

Android 스튜디오에 'FocusTargetTabTest'를 실행하는 컨텍스트 메뉴가 표시됩니다.

11. 축하합니다

잘하셨습니다. 키보드 포커스 관리의 기본 구성 요소를 알아봤습니다.

  • 포커스 타겟
  • 포커스 순회

다음 Compose 수정자를 사용하여 포커스 순회 순서를 제어할 수 있습니다.

  • focusGroup 한정자
  • focusProperties 한정자

하드웨어 키보드, 초기 포커스, 포커스 복원과 함께 UX의 일반적인 패턴을 구현했습니다. 이러한 패턴은 다음 API를 결합하여 구현됩니다.

  • FocusRequester 클래스
  • focusRequester 한정자
  • focusRestorer 한정자
  • LaunchedEffect 컴포저블 함수

구현된 UX는 계측 테스트로 테스트할 수 있습니다. Compose는 키 누름을 실행하고 SemanticsNode에 키보드 포커스가 있는지 테스트하는 방법을 제공합니다.

자세히 알아보기