순회 순서 제어

기본적으로 Compose 앱의 접근성 스크린 리더 동작은 예상되는 읽기 순서로 구현됩니다. 이는 일반적으로 왼쪽에서 오른쪽, 그런 다음 위에서 아래로 진행됩니다. 그러나 추가 힌트 없이는 알고리즘이 실제 읽기 순서를 결정할 수 없는 앱 레이아웃 유형이 있습니다. 뷰 기반 앱에서는 traversalBeforetraversalAfter 속성을 사용하여 이러한 문제를 해결할 수 있습니다. Compose 1.5부터 Compose는 똑같이 유연한 API를 제공하지만 새로운 개념 모델을 제공합니다.

isTraversalGrouptraversalIndex는 기본 정렬 알고리즘이 적절하지 않은 시나리오에서 접근성 및 TalkBack 포커스 순서를 제어할 수 있는 시맨틱 속성입니다. isTraversalGroup는 의미상 중요한 그룹을 식별하는 반면 traversalIndex는 이러한 그룹 내에서 개별 요소의 순서를 조정합니다. isTraversalGroup만 단독으로 사용하거나 추가 맞춤설정을 위해 traversalIndex와 함께 사용할 수 있습니다.

앱에서 isTraversalGrouptraversalIndex를 사용하여 스크린 리더 순회 순서를 제어합니다.

isTraversalGroup를 사용하여 요소 그룹화

isTraversalGroup시맨틱 노드가 순회 그룹인지 여부를 정의하는 불리언 속성입니다. 이 유형의 노드는 노드의 하위 요소를 구성할 때 경계 또는 경계선 역할을 하는 노드입니다.

노드에 isTraversalGroup = true를 설정하면 다른 요소로 이동하기 전에 해당 노드의 모든 하위 요소를 방문합니다. 스크린 리더가 아닌 포커스 가능 노드(예: 열, 행, 상자)에는 isTraversalGroup를 설정할 수 있습니다.

다음 예에서는 isTraversalGroup를 사용합니다. 4개의 텍스트 요소를 내보냅니다. 왼쪽의 두 요소는 하나의 CardBox 요소에 속하고, 오른쪽 두 요소는 다른 CardBox 요소에 속합니다.

// CardBox() function takes in top and bottom sample text.
@Composable
fun CardBox(
    topSampleText: String,
    bottomSampleText: String,
    modifier: Modifier = Modifier
) {
    Box(modifier) {
        Column {
            Text(topSampleText)
            Text(bottomSampleText)
        }
    }
}

@Composable
fun TraversalGroupDemo() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is "
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
            topSampleText1,
            bottomSampleText1
        )
        CardBox(
            topSampleText2,
            bottomSampleText2
        )
    }
}

이 코드는 다음과 비슷한 출력을 생성합니다.

왼쪽 열에는 'This sentence is in the left column'이, 오른쪽 열에는 'This sentence is on the right'이 표시된 두 개의 텍스트 열이 있는 레이아웃입니다.
그림 1. 두 문장으로 구성된 레이아웃 (왼쪽 열에 하나는 오른쪽 열에 하나씩)

시맨틱이 설정되지 않았으므로 스크린 리더의 기본 동작은 왼쪽에서 오른쪽으로, 위에서 아래로 요소를 순회하는 것입니다. 이 기본값 때문에 TalkBack은 문장 조각을 잘못된 순서로 읽습니다.

'This sentence is in' → 'This sentence is' → 'the left column.' → '오른쪽에.'

프래그먼트를 올바르게 정렬하려면 원래 스니펫을 수정하여 isTraversalGrouptrue로 설정하세요.

@Composable
fun TraversalGroupDemo2() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is"
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
//      1,
            topSampleText1,
            bottomSampleText1,
            Modifier.semantics { isTraversalGroup = true }
        )
        CardBox(
//      2,
            topSampleText2,
            bottomSampleText2,
            Modifier.semantics { isTraversalGroup = true }
        )
    }
}

isTraversalGroup는 각 CardBox에서 구체적으로 설정되므로 요소를 정렬할 때 CardBox 경계가 적용됩니다. 이 경우 왼쪽 CardBox을 먼저 읽은 후 오른쪽 CardBox를 읽습니다.

이제 TalkBack은 문장 조각을 올바른 순서로 읽습니다.

"이 문장은" → "왼쪽 열" → '이 문장은' → '오른쪽에.'

순회 순서 추가 맞춤설정

traversalIndex은 TalkBack 순회 순서를 맞춤설정할 수 있는 부동 속성입니다. 요소를 그룹화하는 것만으로 TalkBack이 올바르게 작동하지 않는 경우 traversalIndexisTraversalGroup와 함께 사용하여 스크린 리더 순서를 추가로 맞춤설정합니다.

traversalIndex 속성에는 다음과 같은 특성이 있습니다.

  • traversalIndex 값이 낮은 요소에 우선순위가 높습니다.
  • 양수 또는 음수일 수 있습니다.
  • 기본값은 0f입니다.
  • 텍스트나 버튼과 같은 화면상의 요소와 같이 스크린 리더에 포커스를 둘 수 있는 노드에만 영향을 미칩니다. 예를 들어 열에 isTraversalGroup도 설정되어 있지 않으면 열에 traversalIndex만 설정해도 아무 효과가 없습니다.

다음 예는 traversalIndexisTraversalGroup를 함께 사용하는 방법을 보여줍니다.

예: 순회 시계 페이스

시계 페이스는 표준 순회 순서가 작동하지 않는 일반적인 시나리오입니다. 이 섹션의 예는 사용자가 시계 페이스의 숫자를 탐색하고 시와 분 단위의 숫자를 선택할 수 있는 시간 선택 도구입니다.

위에 시간 선택기가 있는 시계 페이스
그림 2. 시계 페이스 이미지

다음의 단순화된 스니펫에는 12부터 시작하여 시계 방향으로 원을 중심으로 12개의 숫자가 그려지는 CircularLayout가 있습니다.

@Composable
fun ClockFaceDemo() {
    CircularLayout {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier) {
        Text((if (value == 0) 12 else value).toString())
    }
}

시계 페이스는 기본적인 왼쪽에서 오른쪽 및 위에서 아래로 순서로 논리적으로 판독되지 않으므로 TalkBack은 숫자를 비순차적으로 읽습니다. 이 문제를 해결하려면 다음 스니펫과 같이 증분 카운터 값을 사용하세요.

@Composable
fun ClockFaceDemo() {
    CircularLayout(Modifier.semantics { isTraversalGroup = true }) {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier.semantics { this.traversalIndex = value.toFloat() }) {
        Text((if (value == 0) 12 else value).toString())
    }
}

순회 순서를 올바르게 설정하려면 먼저 CircularLayout를 순회 그룹으로 만들고 isTraversalGroup = true을 설정합니다. 그런 다음 각 시계 텍스트가 레이아웃에 그려지면 상응하는 traversalIndex를 카운터 값으로 설정합니다.

카운터 값이 계속 증가하기 때문에 화면에 숫자가 추가될 때 각 클록 값의 traversalIndex는 더 커집니다. 클록 값 0의 traversalIndex는 0이고 클록 값 1의 traversalIndex는 1입니다. 이렇게 하면 TalkBack에서 항목을 읽는 순서가 설정됩니다. 이제 CircularLayout 내부의 숫자가 예상한 순서대로 읽힙니다.

설정된 traversalIndexes는 동일한 그룹화 내의 다른 색인에만 상대적이므로 화면 순서의 나머지 부분은 유지됩니다. 즉, 앞의 코드 스니펫에 표시된 의미론적 변경사항은 isTraversalGroup = true가 설정된 시계 페이스 내의 순서만 수정합니다.

CircularLayout's 의미 체계를 isTraversalGroup = true로 설정하지 않아도 traversalIndex 변경사항이 계속 적용됩니다. 그러나 이를 바인딩하는 CircularLayout가 없으면 화면의 다른 모든 요소를 방문한 후 시계 페이스의 12자리가 마지막으로 읽힙니다. 이는 다른 모든 요소의 기본 traversalIndex0f이고 시계 텍스트 요소가 다른 모든 0f 요소 이후에 읽히기 때문입니다.

예: 플로팅 작업 버튼의 순회 순서 맞춤설정

이 예에서 traversalIndexisTraversalGroup는 Material Design 플로팅 작업 버튼 (FAB)의 순회 순서를 제어합니다. 이 예의 기초는 다음과 같은 레이아웃입니다.

상단 앱 바, 샘플 텍스트, 플로팅 작업 버튼, 하단 앱 바가 있는 레이아웃
그림 3. 상단 앱 바, 샘플 텍스트, 플로팅 작업 버튼, 하단 앱 바가 있는 레이아웃

기본적으로 이 예의 레이아웃에는 다음과 같은 TalkBack 순서가 있습니다.

상단 앱 바 → 샘플 텍스트 0~6 → 플로팅 작업 버튼 (FAB) → 하단 앱 바

스크린 리더가 먼저 FAB에 포커스를 맞추도록 할 수 있습니다. FAB와 같은 Material 요소에 traversalIndex를 설정하려면 다음을 실행하세요.

@Composable
fun FloatingBox() {
    Box(modifier = Modifier.semantics { isTraversalGroup = true; traversalIndex = -1f }) {
        FloatingActionButton(onClick = {}) {
            Icon(imageVector = Icons.Default.Add, contentDescription = "fab icon")
        }
    }
}

이 스니펫에서 isTraversalGrouptrue로 설정된 상자를 만들고 동일한 상자에 traversalIndex를 설정하면(-1f0f의 기본값보다 낮음) 플로팅 상자가 화면의 다른 모든 요소 앞에 옵니다.

이제 플로팅 상자와 기타 요소를 Scaffold에 배치하여 Material Design 레이아웃을 구현합니다.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColumnWithFABFirstDemo() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Top App Bar") }) },
        floatingActionButtonPosition = FabPosition.End,
        floatingActionButton = { FloatingBox() },
        content = { padding -> ContentColumn(padding = padding) },
        bottomBar = { BottomAppBar { Text("Bottom App Bar") } }
    )
}

TalkBack은 다음 순서로 요소와 상호작용합니다.

FAB → 상단 앱 바 → 샘플 텍스트 0~6 → 하단 앱 바

추가 리소스