안녕하세요, 우리동네GS 앱 개발을 하고 있는 O4O개발팀 박상욱 매니저입니다.
우리동네GS 앱에서 Screen Reader 에 대응한 경험을 공유하고자 합니다.
우리동네GS 는 바로배달과 같은 온라인 서비스뿐만 아니라 GS25, GS THE FRESH 에서 QR 결제, 나만의 냉장고 보관/꺼내먹기와 같은 오프라인 서비스도 지원하고 있습니다.
다양한 서비스를 제공하고 있는 우리동네GS에서 복잡도가 높은 홈 화면, QR 결제 화면에 Screen Reader 를 대응한 경험을 중점적으로 공유하고자 합니다.
개인적으로는 이전에 웹 개발을 하면서 HTML 마크업 태그를 그 용도에 맞게 사용한다던지, 이미지에는 alt 속성을 통하여 이미지에 대한 설명을 제공하는 것과 같은 방식의 웹 접근성에 대한 부분만 알고 있었으나, 앱 접근성에 대한 경험은 없었습니다.
이번 경험을 통해 앱, 특히 Flutter 에서의 앱 접근성에 대한 대응을 어떻게 해야 하는지 공부를 많이 할 수 있는 기회가 되었고, 하나하나 적용해 나간 내용들을 간단하게 정리해 보았습니다.
Screen Reader
Screen Reader 는 앱 접근성의 한 기능으로, 모바일 앱 이용할 때 화면에 보이는 영역들을 읽어주고 다음 동작을 안내해주는 역할을 수행합니다. 대표적으로 VoiceOver 와 TalkBack 이 있으며, 각각 iOS 와 Android 에서 지원하는 Screen Reader 기능입니다.
해당 기능을 활성화하면, 스와이프, 연속 터치 등과 같은 특정 제스처를 통해 앱 화면에 나와 있는 정보들을 읽어주며, 앱을 사용하는데 보조해줍니다.
Flutter 의 Screen Reader 지원
Flutter 에서는 기본적으로 Widget Tree 와 함께 Semantics Tree 를 생성합니다. 앱 생성 시, SemanticeNodes 기반의 Semantics Tree 를 생성하며, 각 노드들에 있는 정보들을 Screen Reader 가 접근하여 해당 위젯들을 표현하는 방식으로 동작합니다.
Flutter 에서 사용하는 Text, Button 과 같이 built-in 되어 있는 Widget 들은 Semantics Tree 에 생성되며, 모두 기본적인 semantics 동작들을 지원합니다.
return Semantics(
button: true,
enabled: onPressed != null,
child: InkResponse(
focusNode: focusNode,
autofocus: autofocus,
canRequestFocus: onPressed != null,
onTap: onPressed,
mouseCursor: mouseCursor ?? (onPressed == null ? SystemMouseCursors.basic: SystemMouseCursors.click),
enableFeedback: effectiveEnableFeedback,
focusColor: focusColor ?? theme.focusColor,
hoverColor: hoverColor ?? theme.hoverColor,
highlightColor: highlightColor ?? theme.highlightColor,
splashColor: splashColor ?? theme.splashColor,
radius: splashRadius ?? math.max(
Material.defaultSplashRadius,
(effectiveIconSize + math.min(effectivePadding.horizontal, effectivePadding.vertical)) * 0.7,
// x 0.5 for diameter -> radius and + 40% overflow derived from other Material apps.
),
child: result,
),
);
위 코드는 Flutter material 라이브러리에서 제공하는 IconButton 입니다.
보시다시피, Semantics 위젯으로 감싸져 있고, button, enabled 와 같은 Semantics 프로퍼티에 대하여 기본적으로 대응이 되어 있는 것을 볼 수 있습니다.
이와 같이 Flutter 에서 기본적으로 제공해주는 위젯에는 별도로 Semantics 위젯을 적용하지 않아도, Screen Reader 들은 Text 위젯 내에 있는 글을 읽어주고, 버튼 여부를 인지하여 안내해줍니다.
하지만, 다양한 custom widget 을 사용하는 경우에 이와 같은 기본적인 지원으로는 한계가 존재합니다.
아래 영상은 Screen Reader 에 대한 대응을 하기 전 우리동네GS 앱에서 Screen Reader 가 인식되는 방식이었습니다.
소리가 없지만, 아이콘이 무슨 의미인지 설명 못하는 것뿐만 아니라, 위젯 영역도 제각각 잡히는 것을 볼 수 있습니다.
전체가 아닌 일부가 잡힌다거나, 반대로 잡히지 않았으면 하는 위젯이 잡히기도 합니다.
이처럼, UI 에 맞게 설명을 customize 하고 싶은 경우를 위해 Semantics 위젯을 사용했습니다.
Semantics
Semantics 로 감싸진 UI 위젯이 갖고 있는 의미를 설명해 주기 위한 용도로, child 위젯에 대한 주석을 다는 것과 같은 역할을 수행합니다.
Semantics 위젯은 다양한 프로퍼티들을 갖고 있으며, 이를 통해 해당 UI 위젯에 대한 자세한 설명을 적용할 수 있습니다.
대표적인 파라미터와 프로퍼티를 몇 가지 소개해드리면 아래와 같습니다.
- explicitChildNodes : 해당 노드의 자손 위젯들의 semantic 정보들을 허용 여부
- Padding 이나 Container 같은 위젯 아래 Column 이나 Row 로 묶인 위젯들이 있는 경우, Padding 이나 Container 를 무시하고 자식 위젯들에 대한 정보만 보여주고 싶을 경우에 유용하게 사용 가능합니다.
- excludeSemantics : 해당 노드의 child 위젯들에 있는 semantic 정보들을 모두 무시할지 여부
- ExcludeSemantics 위젯과 동일한 기능을 합니다.
- label : 해당 위젯에 대한 텍스트 설명
- button : 해당 위젯이 버튼인지 여부
- onTapHint : 해당 위젯을 탭 했을 때 발생할 동작에 대한 설명
이외에도 다양한 프로퍼티와 addAction 메서드를 제공하며, 이를 활용하여 해당 UI 위젯에 적절하고 상세한 설명을 생성할 수 있습니다.
Semantics class - widgets library - Dart API
다른 Semantics 위젯들
Semantics 외에도 Flutter 에서는 다른 Semantics 위젯들을 제공합니다.
- ExcludeSemantics
- Screen Reader 가 읽을 필요가 없는 것들에 대해서 제외하도록 처리합니다.
- Semantics 의 excludeSemantics 를 true 로 설정한 것과 동일합니다.
- MergeSemantics
- 같은 요소로 판단되는 UI 위젯들을 하나의 Semantics 로 묶어줍니다.
- 예를 들어, checkbox 에서 label 과 checkbox 을 묶을 때 사용할 수 있습니다.
- IndexedSemantics
- Listview 내부 위젯과 같이 연속성이 존재하는 위젯을 설명하고자 하는 상황에서, 특정 index 의 위젯만 설명하고 싶은 경우 사용합니다.
- BlockSemantics
- blocking 프로퍼티와 같은 기능을 하는 위젯으로, 같은 Semantics 위젯에 속한 다른 Semantics 위젯들을 Semantics Tree 에 미노출하거나 자기 자신을 미노출할 수 있게 동작합니다.
- 특정 위젯 뒤에 그려진 위젯 등을 숨기고자 할 때 사용하며, dialog 나 popup 을 노출할 때, 백그라운드 화면을 인식 못하게 하고자 할 때 주로 사용합니다.
적용 예시
우리동네GS 홈 화면을 보면서, Semantics 를 실제로 적용한 코드들과 함께 앞서 소개한 기능들을 어떻게 활용했는지 공유해드리겠습니다.
위 영상에서 크게 3가지 Screen Reader 관련 대응 내용을 소개하고자 합니다.
1. Semantics 적용
위 영상에서 가장 상단에 있는 주소, 챗봇, 알림, 장바구니 버튼 위젯들을 선택할 때마다, 각 영역에 대한 설명을 label 을 통해 추가하였습니다.
Semantics(
label: ´알림함 바로가기´,
button: true,
child: InkWell(
onTap: () {
// 알림함 페이지 이동
},
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
child: ExcludeSemantics(
child: Container(
width: 40.0,
height: 40.0,
alignment: Alignment.center,
child: SvgPicture.asset(´assets/icons_notification_alert.svg´),
),
),
),
);
위 코드는 알림함 버튼 위젯에 대한 코드입니다.
InkWell 위젯 안에 이미지를 넣어, InkWell 을 통해 onTap 이벤트를 받도록 구성되어 있습니다.
여기서, 해당 버튼이 알림함 바로가기 버튼임을 안내하기 위에 label 에 ‘알림함 바로가기’ 라는 정보를 넣었고, 버튼이기 때문에 button 의 값을 true 로 줬습니다. 추가적으로, 자식이 갖고 있는 이미지에 대한 안내를 무시하기 위해 해당 위젯을 ExcludeSemantics 로 감싸주었습니다.
2. explicitChildNodes 의 사용
우리동네GS 홈 화면은 양쪽에 공통된 여백이 존재하기에 여러 개의 위젯들을 하나의 Padding 으로 묶어서 보여주고 있습니다.
Padding 에 대한 별다른 조치 없이 Screen Reader 를 실행하면 아래와 같이, Padding 위젯의 영역만큼 먼저 잡히고 순차적으로 자식 위젯들이 잡히는 것을 볼 수 있습니다.
이와 같이 해당 Padding 위젯을 스크린 리더에 잡히지 않고, 자식, 자손들만 잡히게 하기 위해서 explicitChildNodes 를 사용했습니다.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Semantics(
explicitChildNodes: true,
child: Column(
children: [
_buildNoticeList(context),
_buildBanner(),
...,
],
),
),
)
explicitChildNodes 를 true 설정할 경우, 스크린 리더는 Padding 영역을 무시하고 바로 자손들에 대한 영역들에 대한 설명을 이어갑니다. 이처럼 자식 위젯들을 살리면서 부모 위젯에 대한 인식을 무시하고자 할 때, explicitChildNodes 를 유용하게 사용할 수 있습니다.
3. Flutter 위젯들의 기본 제공 semantics 활용
Text 위젯에는 기본적으로 받는 data 외에 semanticsLabel 이라는 파라미터가 있습니다.
이 경우, 화면에는 data 에 있는 문구가 노출되지만, 스크린 리더의 경우, semanticsLabel 에 할당된 값을 읽어줍니다.
Text(
´많이 찾는 서비스´,
semanticsLabel: ´많이 찾는 서비스´,
style: TextStyle(
color: Colors.color_1e2530,
fontSize: 18,
fontWeight: FontWeight.w700,
),
)
적용하면서 겪었던 이슈들
Screen Reader 를 Flutter 에서 처음 대응해보면서 경험한 몇 가지 이슈들도 같이 공유하고자 합니다.
1. 배너의 자동재생 이슈
우리동네GS 홈 화면 상하단에는 배너 영역이 있으며, 일정 주기마다 다음 배너로 넘어가도록 자동재생됩니다.
이 때, 스크린 리더들은 배너 내부에 있는 이미지와 데이터를 인지하여 읽어주기 때문에, 자동재생 시간이 지나면 해당 배너에 대한 정보를 다 읽어주기 전에 다음 배너로 넘어가버리는 현상이 발생했습니다.
또한, 다음 배너를 지속해서 읽어주는 것이 아닌 스크린 리더가 인지하고 있는 영역이 사라져 버렸기 때문에, 홈 화면이 가장 첫 영역(주소 영역)으로 자동 포커스 잡혀 버리는 이슈가 있었습니다.
이에 대하여 초기에는 각각 배너에 대하여 스크린 리더가 읽히도록 하지 않고, 배너 영역 전체를 읽히도록 수정해보았습니다.
배너 영역 전체에 대해서 스크린 리더가 잡힘으로써, 배너 이미지가 넘어가더라도 해당 영역에서 포커스를 잃지 않고 유지할 수 있었습니다.
다만, 기존에 3초라는 짧은 시간 동안 배너 이미지에 대한 정보를 제공하기에는 시간이 부족하여 이 부분에 대한 해결 방안도 고민하였습니다.
기획자와 관련 이슈에 대하여 논의해 본 결과, 스크린 리더를 사용할 때 배너가 자동재생되면 원하는 배너 콘텐츠에 대한 설명을 듣기도 힘드며, 혼란을 줄 수 있다고 판단하여 스크린 리더가 켜져 있는 경우 배너에 대한 자동재생을 끄도록 결정하였습니다.
Flutter 에서는 기기에 스크린 리더 기능이 켜져 있는지 MediaQuery 에 있는 accessibleNavigation 값으로 파악할 수 있습니다.
이에, 배너 자동재생 여부 값을 MediaQuery.of(context).accessibleNavigation 으로 할당하여 자동재생 기능을 조절하였습니다.
https://api.flutter.dev/flutter/dart-ui/AccessibilityFeatures/accessibleNavigation.html
2. OS 마다 다른 위젯 인지 차이
Flutter 는 하나의 코드로 2개의 OS 에 대응해야 되며, 이는 스크린 리더에 대해서도 똑같습니다.
작업을 하면서 Android 의 스크린 리더인 TalkBack 에서는 인지하지 않으나, iOS 의 스크린 리더인 VoiceOver 에서는 인지하는 특정 위젯들이 존재했습니다.
위는 Voiceover 에서 우리동네GS 앱을 인지하는 것에 대한 gif 로, 최초에 가장 바깥에 테두리가 잡히고 다음 영역들이 이어서 잡히는 것을 볼 수 있습니다.
TalkBack 의 경우에는 위와 같이 전체를 따로 인지하지 않지만, VoiceOver 는 2개의 node 가 있는 것처럼 인지하고 있었습니다.
해당 현상에 대하여 분석을 진행하였고, FocusDetector 패키지에서 받아 사용하고 있는 위젯에 대한 스크린 리더들의 인식 차이가 있다는 것을 발견했습니다.
- 우리동네GS 에서는 현재 화면에 노출되고 있는지, 백그라운드에서 다시 돌아왔는지 등을 파악하기 위해 FocusDetector 라는 위젯으로 Scaffold 를 감싸여 커스텀한 Scaffold 를 만들어 사용하고 있습니다.
- 홈 화면에서 사용된 Scaffold 를 감싼 FocusDetector 위젯에 대한 스크린 리더들의 인식 차이 때문에 위와 같은 현상이 발생하고 있었던 것입니다.
이를 해결하기 위해, 스크린 리더가 FocusDetector 위젯에 대한 인식을 하지 못하도록 수정해야 했고 scopesRoute, explicitChildNodes 프로퍼티를 활용하여 해결하였습니다.
Flutter 공식 문서에 보면, scopesRoute 프로퍼티를 explicitChildNodes 와 함께 사용했을 때, 해당 위젯의 초점을 잡히지 않도록 설정된다고 안내합니다.
이에 아래와 같이, scopesRoute 와 explicitChildNodes 를 모두 true 로 설정하여 2개의 OS 모두 FocusDetector 위젯에 초점을 잡히지 않고 주소 위젯부터 초점이 잡힐 수 있게 수정하였습니다.
Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: FocusDetector(
onVisibilityGained: onVisibilityGained,
on-focusGained: on-focusGained,
onVisibilityLost: onVisibilityLost,
child: Theme(
data: Theme.of(context).copyWith(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
),
child: Scaffold(...),
),
),
);
https://api.flutter.dev/flutter/semantics/SemanticsProperties/scopesRoute.html
결론
이번 기회를 통해, 현재 우리가 바라보는 앱과 시각장애인이 바라보는 앱의 차이를 느껴보며, 프론트엔드 개발자로서 다양성과 앱 접근성을 고려하여 개발하는 것의 중요성을 느낄 수 있었습니다. 홈 화면 상단 배너의 자동재생을 조절한 것처럼 단순히 읽어주는 기능만으로 앱 접근성을 고려하는 것이 아닌, 좀 더 시각장애인분들의 입장에서 어떻게 하면 앱을 편리하게 사용할 수 있을지 고민하는 시각을 키울 수 있었습니다.
또한, 기존에 Flutter 에서 앱 접근성 관련 정리된 내용들이 많이 없었으나, 이번 기회를 통해 Flutter 에서 앱 접근성 관련 내용을 공부하고 정리하며, 제 경험을 공유까지 할 수 있는 좋은 기회가 된 것 같습니다. 이 글이 작게나마 Flutter 로 앱 접근성을 고민하는 분들에게 도움되었으면 합니다.
박상욱 Simon | DX본부 > O4O개발팀
우리동네GS APP / WEB 개발을 담당하고 있습니다.
새로운 시도를 즐기는 주니어 개발자입니다.
'APP' 카테고리의 다른 글
엔터프라이즈 MSA 이야기 4탄 – GS SHOP 주문서비스팀의 현대화 여정 (2) | 2023.10.25 |
---|---|
Flutter Code Push의 고찰 (7) | 2023.10.11 |
엔터프라이즈 MSA 이야기 3탄 – SR 도메인 편 (9) | 2023.08.01 |
우리동네GS BFF 구현기 Step 1 - 도입 배경과 설계 (1) | 2023.07.28 |
Flutter App 실시간 CDN 이미지 변경 상태 적용 방안 (1) | 2023.06.20 |