아티클

Flutter에서 접근성 초점 순서 관리하기

엔비전스 접근성 2024-04-19 10:09:53

안녕하세요, 엔비전스입니다.

오랜만에 Flutter를 주제로 한 아티클로 찾아뵙게 되었습니다. Flutter는 어떻게 하면, 중복된 UI를 다른 언어 코드로 작성하고, 빌드하는 시간을 줄일지 고민한 끝에 도달한 결과물입니다. Google에서 Flutter를 만든 만큼, Google 앱에서 널리 사용되고 있습니다. 국내 앱 중에서도 Flutter를 사용한 앱을 몇몇 만나볼 수 있습니다.

Flutter는 기본적으로 iOS와 Android의 호환성 문제를 어떻게든 해결하고자 노력한 흔적이 보이는 프레임워크로서, 사용성이나 접근성 측면에서 만족감은 사람마다 다르겠지만, 한 서비스 앱에서 동일한 접근성/사용성 경험을 주기에는 아주 좋은 대안입니다. 당연하게도 각각의 네이티브 선언형 UI(Android: Jetpack Compose, iOS: SwiftUI)보다는 다소 안정성이 떨어질 수 있고, 네이티브를 완전히 구현하지 못하지만, 앞으로 널리 사용될 가능성이 높습니다.

이번 시간에는, 이 Flutter 레이아웃 안에서 초점 순서가 맞지 않을 때, 어떻게 초점 순서를 관리하는지를 알아보도록 하겠습니다.

접근성 오류 방지: Flutter의 기본 선형 정렬 레이아웃 위젯, Column과 Row를 순서에 맞게 적극 사용한다

안타깝게도, 세계적으로나 국내 상황으로나, 기업 입장에서 앱을 개발할 때, 접근성은 처음부터 고려되지 않습니다. 앱을 어느 정도 완성한 후, 접근성을 적용하려고 부단히 노력하는 케이스가 훨씬 많은 것을 체감할 수 있습니다. 그러나, 접근성이 처음부터 고려되는 것이 좋습니다.

마치, 사람이 병에 걸리고 그 병을 치료하는 것보다, 예방하는 것이 장기적으로 봤을 때, 돈과 자원이 덜 드는 것처럼, 접근성이나 사용성도 마찬가지입니다. 처음에 먼저 이를 고려한다면, 더 적은 노력으로 유지보수가 가능합니다.

기본적으로 Flutter는 내부적으로 선언된 순서와 사람이 가장 보편적으로 콘텐츠를 읽는 순서를 고려하여 접근성 초점 순서를 적용합니다. 아랍권을 제외한 대부분의 글자는 기본적으로, 왼쪽으로부터 오른쪽으로 콘텐츠를 읽고 다음 줄을 읽습니다. Flutter는 기본적으로, 이 원칙을 철저히 따릅니다.

Flutter에는 Row(행)와 Column(열)이라는 연속된 데이터를 순서대로 정렬하는 레이아웃 위젯이 있습니다. Row는 안에 오는 위젯 배열을 왼쪽에서 오른쪽으로 가로로 배치하고, Column은 위에서 아래로 세로로 배치합니다. 그런데, 엉뚱한 발상을 잠깐 해봅시다.

이런 걸 설명할 때, 제가 자주 쓰는 배열입니다. 한 개의 행이 있고, 그 안에, 열이 3개가 있습니다. 각 열에는 [1, 4, 7] [2, 5, 8] [3, 6, 9]라는 텍스트 위젯이 들어 있습니다. 눈으로 보기에는 Row 안에 Column이 들어있기 때문에, Column 안에 Row 3개를 넣고, 각각 숫자를 순서대로 배치한 것과 별반 다르지 않게 보입니다. 이런 경우, 초점 순서는 어떨까요?

놀랍게도 예상과는 달랐습니다

SwiftUI의 경우, HStack 안에 VStack을 넣어서 비슷한 구조를 만드는 경우, 무조건, 선언 순서를 기준으로 초점 순서가 정해집니다. 그래서, VStack(세로 스택)에 들어있는 순서대로, 1, 4, 7을 탐색한 뒤에 2, 5, 8을 탐색하게 됩니다.

그런데, Flutter는 Row나 Column 등을 활용했을 때, 선언 순서보다 사람이 읽는 순서가 우선됩니다. 마치, 눈으로 구분할 수 없는 것과 똑같이, 아무리 이렇게 배치해도, 스크린리더는 1, 2, 3과 같이 순서대로 텍스트를 탐색하게 됩니다.

이를 통해 알 수 있는 부분은, 기본적인 Column이나 Row, Flex와 같은 레이아웃 위젯을 순서에 맞게 잘 사용한다면, 초점이 어긋날 일이 매우 적다는 사실입니다. 즉, 초점 순서가 선형 순서로부터 어긋나는 오류가 예방되는 것입니다.

그러면 어떨 때 초점 순서가 틀어질까?

GridView나 ListView를 진행 순서와 다르게 나열하여 사용할 때, 이 문제가 생기게 됩니다. 예를 들어서, 아까, Column과 Row로 숫자를 나열한 것처럼 9개의 박스를 3열 3행으로 나열하는 목록을 만들고 싶다고 가정해 봅시다.

리스트는 기본적으로 위에서 아래로 읽는 게 일반적입니다. 그런데, Flutter의 GridView는 기본적으로는 데이터가 쌓이는 축을 바꿀 수 없습니다. 무조건, 아이템 카운트를 정해두면, 왼쪽에서 오른쪽으로 쌓이고, 정해진 카운트를 넘어서면 행을 하나 더 만들어서 계속 쌓는 구조입니다.

그런데, 개발자나 기획자는 때에 따라서 세로로 다 읽고, 다음 열로 가게 하고 싶을 수도 있습니다. 이럴 때, 데이터의 순서를 원하는 개수와 나눠서 원래 순서와는 관계없이 데이터를 나열할 수 있습니다. 아래 사례를 보시지요.

Scafold(
        //...
        body: Column ( 
            children: [
                const Text("Before:"),
                Expanded (
                child:GridView.count(
                    crossAxisCount: 3,
                    padding: const EdgeInsets.all(5.0),
                    children: const [
                    Center(child:Text("1")),
                    Center(child:Text("4")),
                    Center(child:Text("7")),
                    Center(child:Text("2")),
                    Center(child:Text("5")),
                    Center(child:Text("8")),
                    Center(child:Text("3")),
                    Center(child:Text("6")),
                    Center(child:Text("9")),
                ])
                    
                ),
            ]
        )
        //...
    );

이렇게 배치하게 되면, 눈으로 보기에는 세로로 쌓이고, 그 다음 열을 읽으면 되는 구조로 보이지만,VoiceOver나 TalkBack은 레이아웃 위젯과 다르게 GridView 안에 있는 데이터는 선언 ’순서대로 초점을 보내게 되므로, 엉뚱한 순서대로 내용을 읽게 되는 겁니다. 인기 콘텐츠 순위 같은 목록을 만들 때 굉장히 치명적이겠지요?

어떻게 고칠까?

방법은 굉장히 심플합니다. sortKey 속성을 지정할 수 있는 Semantics 위젯을 원래 요소에 래핑하여 컴포넌트를 만들면 됩니다.

class NumberItem extends StatelessWidget {
      final String number;
      final OrdinalSortKey? order;
      const NumberItem({super.key, required this.number, this.order});
    
      @override
      Widget build(BuildContext context) {
        return Semantics(
          sortKey:order,
          child: Center(child:Text(number))
        );
      }
    }

OrdinalSotKey가 뭐죠? : OrdinalSortKey(double order, String? name)

위 위젯 클래스를 보면 number라는 문자열과 OrdinalSortKey를 받는 order를 옵셔널 값으로 받도록 했습니다.
OridnalSortKey 안에는 double 값으로 초점 순서를 변경할 수 있습니다. order를 sortKey속성에 전달합니다. name 속성은 초점 순서를 정하는 기준이 되는 그룹 이름입니다. 다만, 아직 name을 통해 초점을 수정해 봤지만, 제대로 작동하지 않는 것 같습니다.

완성된 코드

이제, 우선, name을 지정하지 않고 초점 순서만 지정해서 순서를 바꿔보죠.

새로 만든 컴포넌트를 활용해 조금 전에 초점이 엉망이던 코드를 고쳐봅시다.

Scafold(
        //...
        body: Column ( 
            children: [
                const Text("After:"),
                Expanded (
                    child:GridView.count(
                        crossAxisCount: 3,
                        padding: const EdgeInsets.all(5.0),
                        children: const [
                            NumberItem(number: "1", order: OrdinalSortKey(0)),
                            NumberItem(number: "4", order: OrdinalSortKey(3)),
                            NumberItem(number: "7", order: OrdinalSortKey(6)),
                            NumberItem(number: "2", order: OrdinalSortKey(1)),
                            NumberItem(number: "5", order: OrdinalSortKey(4)),
                            NumberItem(number: "8", order: OrdinalSortKey(7)),
                            NumberItem(number: "3", order: OrdinalSortKey(2)), 
                            NumberItem(number: "6", order: OrdinalSortKey(5)),
                            NumberItem(number: "9", order: OrdinalSortKey(8)),
                        ]
                    )
                    
                ),
            ]
        )
        //...
    );

자, CLI에서 r 키를 눌러서 핫 리로드를 한 다음, 스크린리더를 다시 켜고 테스트해 보세요. 우리가 원하는 대로, 세로로, 위에서 아래로 이동하는 것을 볼 수 있습니다.

sortKey 속성의 특징

sortKey 속성을 지정하고 테스트해 보면 기본적으로는 초점 순서는 부모 컨테이너 콘텍스트를 따르는 걸 알 수 있습니다.

화면 내 모든 요소를 기준으로 절대적으로 순서를 정하는 것이 아니고, GridView 안에서의 순서만 바뀌었다는 걸 알 수 있죠.

만약 그렇지 않고, 절대적인 순서로 지정해야 한다면, 이 GridView 안에 있는 항목이 시작 전에 있는 “After:”라는 텍스트보다 초점이 가야겠지요. 즉, 특정 위젯 안에서 상대적으로 순서를 정하는 것이 OrdinalSortKey입니다.

컨테이너 위치도 바꿀 수 있을까?

ListView ( 
        children: [
            Semantics(sortKey: const OrdinalSortKey(2), child:const Text("Before:")),
            Semantics(sortKey: const OrdinalSortKey(3),
                child:GridView.count(
                    crossAxisCount: 3,
                    shrinkWrap: true,
                    physics: const NeverScrollableScrollPhysics(),
                    padding: const EdgeInsets.all(5.0),
                    children: const [
                        Center(child:Text("1")),
                        Center(child:Text("4")),
                        Center(child:Text("7")),
                        Center(child:Text("2")),
                        Center(child:Text("5")),
                        Center(child:Text("8")),
                        Center(child:Text("3")),
                        Center(child:Text("6")),
                        Center(child:Text("9")),
                    ]
                )
              ),
            Semantics(sortKey: const OrdinalSortKey(0),child:const Text("After:")),
            Semantics(
                sortKey: const OrdinalSortKey(1),
                child:GridView.count(
                    crossAxisCount: 3,
                    shrinkWrap: true,
                    physics: const NeverScrollableScrollPhysics(),
                    padding: const EdgeInsets.all(5.0),
                    children: const [
                        NumberItem(number: "1", order: OrdinalSortKey(0)),
                        NumberItem(number: "4", order: OrdinalSortKey(3)),
                        NumberItem(number: "7", order: OrdinalSortKey(6)),
                        NumberItem(number: "2", order: OrdinalSortKey(1)),
                        NumberItem(number: "5", order: OrdinalSortKey(4)),
                        NumberItem(number: "8", order: OrdinalSortKey(7)),
                        NumberItem(number: "3", order: OrdinalSortKey(2)), 
                        NumberItem(number: "6", order: OrdinalSortKey(5)),
                        NumberItem(number: "9", order: OrdinalSortKey(8)),
                    ]
                )
            ),
        ]
    )

테스트해 보면 잘 바뀌는 걸 볼 수 있습니다. 다만, Android TalkBack은 완전히 가려진 화면에 있는 요소를 무시하고, 눈에 보이는 1, 4, 7부터 탐색하는 걸 볼 수 있습니다. 반면 iOS는 우리가 예상한 순서인, After 텍스트 다음에, After GridView, After GridView 다음에 Before 텍스트, 그다음에 Before GridView로 초점이 잘 가는 걸 볼 수 있습니다.

자, 대략적인 Flutter의 초점 변경 기능에 관해 알아봤습니다. 다른 선언형 UI 프레임워크와 마찬가지로, 굉장히 쉽게, 접근성 초점을 변경할 수 있는 것을 알 수 있습니다. 만약, OrdinalSortKey의 name 매개변수가 올바르게 작동한다면, 조금 더 효율적으로 초점을 조정할 수 있을 것 같습니다.

name 매개변수가 작동한다면, 현재 이 예제를 예로 들어 고쳐본다면, 1, 2, 3은 name을 Col1로, 2, 5, 8은 name을 Col2로, 그리고, 3, 6, 9는 Col3으로 주고, 각각, 0, 1, 2라는 순서를 주면, 조금더 편하게 순서를 지정할 수 있을 것으로 보입니다.

이번 아티클은 여기까지입니다. 접근성을 개선하시는 데, 부디 유용한 자료가 되길 희망하며, 앞으로도 알찬 정보로 찾아뵐 수 있도록 노력하겠습니다.

감사합니다.

댓글 0
댓글을 작성하려면 해주세요.