포커스 동작 변경

화면에 있는 요소의 기본 포커스 동작을 재정의해야 하는 경우가 있습니다. 예를 들어 컴포저블을 그룹화하거나, 특정 컴포저블에서 포커스를 차단하거나, 명시적으로 포커스를 요청하거나, 포커스를 캡처 또는 해제하거나, 시작 또는 종료 시 포커스를 리디렉션할 수 있습니다. 이 섹션에서는 기본값이 필요하지 않은 경우 포커스 동작을 변경하는 방법을 설명합니다.

표적 집단을 활용한 일관된 탐색 기능 제공

Jetpack Compose는 특히 탭과 목록과 같은 복잡한 상위 Composables가 작동하는 경우 탭 탐색의 올바른 다음 항목을 즉시 추측하지 않는 경우가 있습니다.

포커스 검색은 일반적으로 Composables의 선언 순서를 따르지만, 계층 구조의 Composables 중 하나가 완전히 표시되지 않는 가로 스크롤 가능한 경우와 같은 일부 경우에는 불가능할 수 있습니다. 아래 예를 참고하세요.

Jetpack Compose는 단방향 탐색에 예상되는 경로에서 계속하지 않고 아래와 같이 화면 시작 부분에 가장 가까운 다음 항목에 포커스를 둘 수 있습니다.

상단 가로 탐색과 그 아래에 항목 목록을 보여주는 앱의 애니메이션입니다.
그림 1. 상단 가로 탐색과 그 아래의 항목 목록을 보여주는 앱의 애니메이션

이 예에서 개발자는 포커스를 초콜릿 탭에서 아래의 첫 번째 이미지로 이동한 다음 패스트리 탭으로 다시 이동할 의도가 없었음을 알 수 있습니다. 대신 마지막 탭까지 탭에 포커스를 계속 둔 후 내부 콘텐츠에 포커스를 두고자 했습니다.

상단 가로 탐색과 그 아래에 항목 목록을 보여주는 앱의 애니메이션입니다.
그림 2. 상단 가로 탐색과 그 아래의 항목 목록을 보여주는 앱의 애니메이션

이전 예의 Tab 행과 같이 컴포저블 그룹이 순차적으로 포커스를 받는 것이 중요한 상황에서는 focusGroup() 수정자가 있는 상위 요소에서 Composable를 래핑해야 합니다.

LazyVerticalGrid(columns = GridCells.Fixed(4)) {
    item(span = { GridItemSpan(maxLineSpan) }) {
        Row(modifier = Modifier.focusGroup()) {
            FilterChipA()
            FilterChipB()
            FilterChipC()
        }
    }
    items(chocolates) {
        SweetsCard(sweets = it)
    }
}

양방향 탐색은 지정된 방향에서 가장 가까운 컴포저블을 찾습니다. 다른 그룹의 요소가 현재 그룹에서 완전히 표시되지 않는 항목보다 가까우면 탐색은 가장 가까운 컴포저블을 선택합니다. 이 동작을 방지하려면 focusGroup() 수정자를 적용하면 됩니다.

FocusGroup는 포커스 측면에서 전체 그룹을 단일 항목처럼 보이지만 그룹 자체에는 포커스를 받지 않습니다. 대신 가장 가까운 하위 요소에 포커스가 맞춰집니다. 이렇게 하면 탐색은 그룹을 나가기 전에 완전히 표시되지 않는 항목으로 이동하는 것을 인식합니다.

이 경우 FilterChip의 세 인스턴스는 SweetsCards가 사용자에게 완전히 표시되고 일부 FilterChip가 숨겨져 있을 수 있는 경우에도 SweetsCard 항목 앞에 포커스가 있습니다. 이는 focusGroup 수정자가 항목에 포커스가 맞춰지는 순서를 조정하도록 포커스 관리자에 지시하여 탐색이 UI와 더 쉽고 일관성이 되도록 하기 때문입니다.

focusGroup 수정자가 없으면 FilterChipC가 표시되지 않으면 포커스 탐색이 마지막으로 이를 선택합니다. 그러나 이러한 수정자를 추가하면 검색 가능할 뿐만 아니라 사용자가 예상하는 대로 FilterChipB 직후에 포커스를 획득할 수도 있습니다.

컴포저블을 포커스 가능하게 만들기

일부 컴포저블은 기본적으로 포커스를 둘 수 있습니다. 예를 들어 버튼 또는 clickable 수정자가 연결된 컴포저블이 그 예입니다. 특별히 컴포저블에 포커스 가능 동작을 추가하려면 focusable 수정자를 사용합니다.

var color by remember { mutableStateOf(Green) }
Box(
    Modifier
        .background(color)
        .onFocusChanged { color = if (it.isFocused) Blue else Green }
        .focusable()
) {
    Text("Focusable 1")
}

컴포저블을 포커스 불가능하게 만들기

일부 요소가 포커스에 참여하지 않아야 하는 상황이 있을 수 있습니다. 드문 경우지만 canFocus property를 활용하여 Composable를 포커스 가능에서 제외할 수 있습니다.

var checked by remember { mutableStateOf(false) }

Switch(
    checked = checked,
    onCheckedChange = { checked = it },
    // Prevent component from being focused
    modifier = Modifier
        .focusProperties { canFocus = false }
)

FocusRequester를 사용하여 키보드 포커스 요청

경우에 따라 사용자 상호작용에 대한 응답으로 포커스를 명시적으로 요청해야 할 수 있습니다. 예를 들어 사용자에게 양식 작성을 다시 시작할지 물어본 다음 '예'를 누르면 양식의 첫 번째 필드에 포커스를 다시 맞춰야 할 수 있습니다.

가장 먼저 할 일은 FocusRequester 객체를 키보드 포커스를 이동하려는 컴포저블과 연결하는 것입니다. 다음 코드 스니펫에서 FocusRequester 객체는 Modifier.focusRequester라는 수정자를 설정하여 TextField와 연결됩니다.

val focusRequester = remember { FocusRequester() }
var text by remember { mutableStateOf("") }

TextField(
    value = text,
    onValueChange = { text = it },
    modifier = Modifier.focusRequester(focusRequester)
)

FocusRequester의 requestFocus 메서드를 호출하여 실제 포커스 요청을 보낼 수 있습니다. 이 메서드는 Composable 컨텍스트 외부에서 호출해야 합니다(그렇지 않으면 모든 리컴포지션 시 다시 실행됨). 다음 스니펫은 버튼을 클릭할 때 키보드 포커스를 이동하도록 시스템에 요청하는 방법을 보여줍니다.

val focusRequester = remember { FocusRequester() }
var text by remember { mutableStateOf("") }

TextField(
    value = text,
    onValueChange = { text = it },
    modifier = Modifier.focusRequester(focusRequester)
)

Button(onClick = { focusRequester.requestFocus() }) {
    Text("Request focus on TextField")
}

캡처 및 초점 해제

포커스를 활용하여 사용자가 앱에서 작업을 실행하는 데 필요한 적절한 데이터(예: 유효한 이메일 주소 또는 전화번호 가져오기)를 제공하도록 안내할 수 있습니다. 오류 상태를 통해 사용자에게 현재 상황을 알 수 있지만 문제가 해결될 때까지 잘못된 정보가 포함된 필드에 초점을 맞춰야 할 수 있습니다.

포커스를 캡처하려면 captureFocus() 메서드를 호출한 후 다음 예와 같이 대신 freeFocus() 메서드를 사용하여 이 메서드를 호출하면 됩니다.

val textField = FocusRequester()

TextField(
    value = text,
    onValueChange = {
        text = it

        if (it.length > 3) {
            textField.captureFocus()
        } else {
            textField.freeFocus()
        }
    },
    modifier = Modifier.focusRequester(textField)
)

포커스 수정자의 우선순위

Modifiers은 하나의 하위 요소만 있는 요소로 볼 수 있습니다. 따라서 이러한 요소를 큐에 추가할 때 왼쪽 (또는 상단)의 각 Modifier는 오른쪽 (또는 아래)을 따라오는 Modifier를 래핑합니다. 즉, 두 번째 Modifier가 첫 번째 항목 내에 포함되어 있으므로 두 개의 focusProperties를 선언할 때 다음 항목이 최상위에 포함되므로 최상위 항목만 작동합니다.

개념을 더 명확하게 이해하려면 다음 코드를 참고하세요.

Modifier
    .focusProperties { right = item1 }
    .focusProperties { right = item2 }
    .focusable()

이 경우 item2를 올바른 포커스로 나타내는 focusProperties은 이전 항목에 포함되어 있으므로 사용되지 않습니다. 따라서 item1가 사용됩니다.

이 접근 방식을 활용하면 상위 요소는 FocusRequester.Default를 사용하여 동작을 기본값으로 재설정할 수도 있습니다.

Modifier
    .focusProperties { right = Default }
    .focusProperties { right = item1 }
    .focusProperties { right = item2 }
    .focusable()

상위 요소는 동일한 수정자 체인의 일부가 아니어도 됩니다. 상위 컴포저블은 하위 컴포저블의 포커스 속성을 덮어쓸 수 있습니다. 예를 들어 다음과 같이 버튼을 포커스 가능하게 만드는 FancyButton를 살펴보겠습니다.

@Composable
fun FancyButton(modifier: Modifier = Modifier) {
    Row(modifier.focusProperties { canFocus = false }) {
        Text("Click me")
        Button(onClick = { }) { Text("OK") }
    }
}

사용자는 canFocustrue로 설정하여 이 버튼을 다시 포커스 가능하게 만들 수 있습니다.

FancyButton(Modifier.focusProperties { canFocus = true })

모든 Modifier와 마찬가지로 포커스 관련 인텐트는 선언된 순서에 따라 다르게 동작합니다. 예를 들어 다음과 같은 코드는 Box를 포커스 가능하게 만들지만 FocusRequester는 포커스 가능 클래스 뒤에 선언되므로 이 포커스 가능 요소와 연결되지 않습니다.

Box(
    Modifier
        .focusable()
        .focusRequester(Default)
        .onFocusChanged {}
)

focusRequester는 계층 구조에서 그 아래에 있는 첫 번째 포커스 가능 항목과 연결되므로 이 focusRequester는 포커스 가능한 첫 번째 하위 요소를 가리킵니다. 사용할 수 있는 것이 없으면 아무것도 가리키지 않습니다. 그러나 focusable() 수정자 덕분에 Box는 포커스 가능하므로 양방향 탐색을 사용하여 이동할 수 있습니다.

또 다른 예로, 다음 중 하나를 실행할 수 있습니다. onFocusChanged() 수정자는 focusable() 또는 focusTarget() 수정자 뒤에 표시되는 첫 번째 포커스 가능 요소를 참조하기 때문입니다.

Box(
    Modifier
        .onFocusChanged {}
        .focusRequester(Default)
        .focusable()
)
Box(
    Modifier
        .focusRequester(Default)
        .onFocusChanged {}
        .focusable()
)

들어가거나 나갈 때 포커스 리디렉션

아래 애니메이션에 표시된 탐색과 같이 매우 구체적인 종류의 탐색을 제공해야 하는 경우도 있습니다.

두 개의 버튼 열이 나란히 배치되어 있고 한 열에서 다른 열로 포커스를 애니메이션으로 보여주는 화면의 애니메이션입니다.
그림 3. 두 개의 버튼 열이 나란히 배치되어 있고 한 열에서 다른 열로 포커스를 애니메이션으로 보여주는 화면의 애니메이션

이를 만드는 방법을 자세히 알아보기 전에 포커스 검색의 기본 동작을 이해하는 것이 중요합니다. 수정하지 않고 포커스 검색이 Clickable 3 항목에 도달하면 D패드 (또는 이에 상응하는 화살표 키)에서 DOWN를 누르면 포커스가 Column 아래에 표시되는 항목으로 포커스가 이동하여 그룹을 나오고 오른쪽 그룹은 무시됩니다. 사용 가능한 포커스 가능 항목이 없으면 포커스는 아무 곳에서도 이동하지 않고 Clickable 3에 유지됩니다.

이 동작을 변경하고 원하는 탐색을 제공하려면 focusProperties 수정자를 활용하면 됩니다. 이 수정자는 포커스 검색이 Composable에 들어가거나 나올 때 발생하는 일을 관리하는 데 도움이 됩니다.

val otherComposable = remember { FocusRequester() }

Modifier.focusProperties {
    exit = { focusDirection ->
        when (focusDirection) {
            Right -> Cancel
            Down -> otherComposable
            else -> Default
        }
    }
}

계층 구조의 특정 부분에 들어가거나 나올 때마다 특정 Composable에 포커스를 지정할 수 있습니다. 예를 들어 UI에 열이 두 개 있고 첫 번째 열이 처리될 때마다 포커스가 두 번째 열로 전환되게 하려는 경우:

두 개의 버튼 열이 나란히 배치되어 있고 한 열에서 다른 열로 포커스를 애니메이션으로 보여주는 화면의 애니메이션입니다.
그림 4. 두 개의 버튼 열이 나란히 배치되어 있고 한 열에서 다른 열로 포커스를 애니메이션으로 보여주는 화면의 애니메이션

이 gif에서 포커스가 Column 1의 Clickable 3 Composable에 도달하면 포커스가 맞춰지는 다음 항목은 다른 ColumnClickable 4입니다. 이 동작은 focusProperties 수정자 내에서 focusDirectionenterexit 값과 결합하여 실행할 수 있습니다. 둘 다 포커스가 시작되는 방향을 매개변수로 취하고 FocusRequester를 반환하는 람다가 필요합니다. 이 람다는 세 가지 방식으로 작동할 수 있습니다. FocusRequester.Cancel를 반환하면 포커스가 중지되지만 FocusRequester.Default는 동작을 변경하지 않습니다. 대신 다른 Composable에 연결된 FocusRequester를 제공하면 포커스가 특정 Composable로 이동합니다.

포커스 진행 방향 변경

포커스를 다음 항목으로 이동하거나 정확한 방향으로 이동하려면 onPreviewKey 수정자를 활용하고 moveFocus 수정자를 사용하여 포커스를 이동하도록 LocalFocusManager를 암시하면 됩니다.

다음 예는 포커스 메커니즘의 기본 동작을 보여줍니다. tab 키 누름이 감지되면 포커스가 포커스 목록의 다음 요소로 이동합니다. 이는 일반적으로 구성해야 하는 것은 아니지만 기본 동작을 변경할 수 있으려면 시스템의 내부 작동을 아는 것이 중요합니다.

val focusManager = LocalFocusManager.current
var text by remember { mutableStateOf("") }

TextField(
    value = text,
    onValueChange = { text = it },
    modifier = Modifier.onPreviewKeyEvent {
        when {
            KeyEventType.KeyUp == it.type && Key.Tab == it.key -> {
                focusManager.moveFocus(FocusDirection.Next)
                true
            }

            else -> false
        }
    }
)

이 샘플에서 focusManager.moveFocus() 함수는 지정된 항목 또는 함수 매개변수에 포함된 방향으로 포커스를 이동합니다.