Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Droid Knights 2024 Accessibility in Android

Nanamare
June 12, 2024

Droid Knights 2024 Accessibility in Android

정부에서 시행한 모바일 앱 접근성 준수 지침이 의무화 되었습니다.
혹시 여러분들은 개발하실 때, 접근성을 고려하고 계시나요?
접근성에 대해 가볍게 알아보고, 향상시키는 기술적 방안들을 소개합니다.
이번 기회에 모든 사용자가 쉽게 사용할 수 있는 앱을 만드는 방법을 함께 배워봅시다!

Nanamare

June 12, 2024
Tweet

Other Decks in Education

Transcript

  1. ❏ 장애인차별금지법 ❏ 2021년 7월 27일 개정, 2023년 1월 28일

    시행 ❏ <1단계:23.7.28> 공공·교육·의료기관, 이동·교통시설 등 ❏ <2단계:24.1.28> 복지시설, 상시 100인 이상 사업주 ❏ <3단계:24.7.28> 문화·예술․관광사업자, 상시 100인 미만 사업주 접근성의 중요성
  2. 다양한 안드로이드 접근성 서비스 ❏ Vision ❏ Lookout(Assisted vision), Reading

    mode, Talkback, Low vision tools ❏ Audio ❏ Live Transcribe & Notification, Live Caption, Hearing aids(보청기), Sound Amplifier ❏ Mobility ❏ Switch(Camera) Access, Project Activate, Voice Access, Action Blocks
  3. Vision - Talkback ❏ 화면 콘텐츠를 음성으로 읽어주는 보조 기술이에요.

    ❏ 다양한 제스처를 통해 화면 요소간의 이동, 선택, 클릭, 스크롤, 컨텍스트 메뉴 등을 제공해요.
  4. Switch Access ❏ 신체적 불편함이 있는 사용자가 화면을 터치하지 않고도,

    하드웨어를 통해 안드로이드 장치를 제어할 수 있어요
  5. 안드로이드 접근성 가이드 라인 1. Increase text visibility(텍스트 가시성 향상)

    2. Use Large, simple controls(더 큰 터치 영역 사용) 3. Describe each UI element (UI 요소 설명) 4. 기타 개선 사항
  6. 텍스트 가시성 향상 ❏ 색상 대비 높이기 ❏ 텍스트 색상과

    배경 색상 간의 밝기 차이를 수치로 나타낸 것이에요 ❏ ColorUtils.calculateContrast(fgColor, bgColor)
  7. 텍스트 가시성 향상 Text type Color contrast ratio Large text

    (anything 18sp or larger, or 14sp and larger when bold) At least 3:1 color contrast ratio All other text At least 4.5 color contrast ratio
  8. 더 큰 터치 영역 ❏ minWidth + paddingRight + paddingLeft

    >= 48dp ❏ minHeight + paddingTop + paddingBottom >= 48dp
  9. 더 큰 터치 영역 ❏ 48dp(약 7 ~ 10 mm)보다

    작으면 IDE 에서 Warning 이 발생해요 <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <Button android:id="@+id/too_small_button" android:layout_width="31dp" android:layout_height="31dp" android:text="@string/too_small_button_text" /> </LinearLayout>
  10. 더 큰 터치 영역 ❏ 48dp x 48 dp 가

    아닌 32dp x 32dp 를 권장하는 이유는 ? // TouchTargetSizeCheck.kt /** * Minimum height and width are set according to * <a href="http://developer.android.com/design/patterns/accessibility.html"></a> * With the modification that targets against the edge of the screen may be narrower. */ private static final int TOUCH_TARGET_MIN_HEIGHT = 48; private static final int TOUCH_TARGET_MIN_WIDTH = 48; private static final int TOUCH_TARGET_MIN_HEIGHT_ON_EDGE = 32; private static final int TOUCH_TARGET_MIN_WIDTH_ON_EDGE = 32; private static final int TOUCH_TARGET_MIN_HEIGHT_IME_CONTAINER = 32; private static final int TOUCH_TARGET_MIN_WIDTH_IME_CONTAINER = 32;
  11. 더 큰 터치 영역 ❏ 48dp x 48 dp 가

    아닌 32dp x 32dp 를 권장하는 이유는 ? // TouchTargetSizeCheck.kt /** * Minimum height and width are set according to * <a href="http://developer.android.com/design/patterns/accessibility.html"></a> * With the modification that targets against the edge of the screen may be narrower. */ private static final int TOUCH_TARGET_MIN_HEIGHT = 48; private static final int TOUCH_TARGET_MIN_WIDTH = 48; private static final int TOUCH_TARGET_MIN_HEIGHT_ON_EDGE = 32; private static final int TOUCH_TARGET_MIN_WIDTH_ON_EDGE = 32; private static final int TOUCH_TARGET_MIN_HEIGHT_IME_CONTAINER = 32; private static final int TOUCH_TARGET_MIN_WIDTH_IME_CONTAINER = 32;
  12. 더 큰 터치 영역 ❏ 48dp x 48 dp 가

    아닌 32dp x 32dp 를 권장하는 이유는 ? // TouchTargetSizeCheck.kt /** * Minimum height and width are set according to * <a href="http://developer.android.com/design/patterns/accessibility.html"></a> * With the modification that targets against the edge of the screen may be narrower. */ private static final int TOUCH_TARGET_MIN_HEIGHT = 48; private static final int TOUCH_TARGET_MIN_WIDTH = 48; private static final int TOUCH_TARGET_MIN_HEIGHT_ON_EDGE = 32; private static final int TOUCH_TARGET_MIN_WIDTH_ON_EDGE = 32; private static final int TOUCH_TARGET_MIN_HEIGHT_IME_CONTAINER = 32; private static final int TOUCH_TARGET_MIN_WIDTH_IME_CONTAINER = 32;
  13. 더 큰 터치 영역 ❏ layout_margin 을 주게 되면 ?

    <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <Button android:id="@+id/too_small_button" android:layout_width="31dp" android:layout_height="31dp" android:layout_margin="20dp" android:text="@string/too_small_button_text" /> </LinearLayout>
  14. 더 큰 터치 영역 ❏ 뷰의 사이즈를 변경해서 터치 영역을

    개선하려는데, ❏ 이미 배포 되어 레이아웃을 수정하기 어렵거나 혹은 ❏ 복잡한 계산으로 커스텀 뷰의 사이즈 직접 조절이 어려운 경우
  15. 더 큰 터치 영역 ❏ ViewSystem(XML) 경우 TouchDelegate 이용해서 뷰의

    사이즈 변경 없이 터치 영역 개선이 가능해요 ❏ 터치 영역을 너무 크게 설정하면, 다른 터치 가능한 UI 요소와 겹칠 수 있기 때문에 주의 필요
  16. 더 큰 터치 영역 // 예시 코드 private fun View.expandTouchArea(size:

    Int) { // 1. 부모 뷰를 가져와요 val parent = this.parent as? View parent?.touchDelegate = TouchDelegate( Rect().apply { // 2. 현재 뷰의 터치 영역을 가져와요 getHitRect(this) // 3. inset 함수를 사용하여 터치 영역을 확장해요 inset(size, size) }, this ) }
  17. 더 큰 터치 영역 // 예시 코드 private fun View.expandTouchArea(size:

    Int) { // 1. 부모 뷰를 가져와요 val parent = this.parent as? View parent?.touchDelegate = TouchDelegate( Rect().apply { // 2. 현재 뷰의 터치 영역을 가져와요 getHitRect(this) // 3. inset 함수를 사용하여 터치 영역을 확장해요 inset(size, size) }, this ) }
  18. 더 큰 터치 영역 // 예시 코드 private fun View.expandTouchArea(size:

    Int) { // 1. 부모 뷰를 가져와요 val parent = this.parent as? View parent?.touchDelegate = TouchDelegate( Rect().apply { // 2. 현재 뷰의 터치 영역을 가져와요 getHitRect(this) // 3. inset 함수를 사용하여 터치 영역을 확장해요 inset(size, size) }, this ) }
  19. 더 큰 터치 영역 ❏ Compose 의 경우 TouchDelegate 제공

    X, 커스텀 처리가 필요해요 ❏ Compose 에서는 터치 사이즈를 강제하는 Material Component 가 존재해서 주의가 필요해요 ❏ Slider, Checkbox, Switch etc..
  20. 더 큰 터치 영역 // Slider.kt Layout( // 생략... ,

    modifier = modifier // 사이즈를 지정한 modifier .minimumInteractiveComponentSize() .requiredSizeIn( minWidth = SliderTokens.HandleWidth, minHeight = SliderTokens.HandleHeight ) .sliderSemantics(state, enabled) .focusable(enabled, interactionSource) .then(press) .then(drag) )
  21. 더 큰 터치 영역 // Slider.kt Layout( // 생략... ,

    modifier = modifier // 사이즈를 지정한 modifier .minimumInteractiveComponentSize() // 터치 가능한 컴포넌트가 가져야 하는 최소 크기 .requiredSizeIn( minWidth = SliderTokens.HandleWidth, minHeight = SliderTokens.HandleHeight ) .sliderSemantics(state, enabled) .focusable(enabled, interactionSource) .then(press) .then(drag) )
  22. 더 큰 터치 영역 // Slider.kt Layout( // 생략... ,

    modifier = modifier // 사이즈를 지정한 modifier .minimumInteractiveComponentSize() // 터치 가능한 컴포넌트가 가져야 하는 최소 크기 .requiredSizeIn( // 슬라이더 핸들 최소 사이즈 보장 minWidth = SliderTokens.HandleWidth, minHeight = SliderTokens.HandleHeight ) .sliderSemantics(state, enabled) .focusable(enabled, interactionSource) .then(press) .then(drag) )
  23. 더 큰 터치 영역 ❏ minimumInteractiveComponentSize() ? ❏ 상호작용 가능한

    컴포넌트가 가져야 하는 최소 크기 강제화
  24. // InterativeComponentSize.kt override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints ):

    MeasureResult { val size = minimumInteractiveComponentSize val placeable = measurable.measure(constraints) val enforcement = isAttached && currentValueOf(LocalMinimumInteractiveComponentEnforcement) // 코드 생략… } 더 큰 터치 영역
  25. // InterativeComponentSize.kt override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints ):

    MeasureResult { val size = minimumInteractiveComponentSize val placeable = measurable.measure(constraints) val enforcement = isAttached && currentValueOf(LocalMinimumInteractiveComponentEnforcement) // 코드 생략… } private val minimumInteractiveComponentSize: DpSize = DpSize(48.dp, 48.dp) 더 큰 터치 영역
  26. 더 큰 터치 영역 // InterativeComponentSize.kt override fun MeasureScope.measure( measurable:

    Measurable, constraints: Constraints ): MeasureResult { val size = minimumInteractiveComponentSize val placeable = measurable.measure(constraints) val enforcement = isAttached && currentValueOf(LocalMinimumInteractiveComponentEnforcement) // 코드 생략… }
  27. 더 큰 터치 영역 ❏ LocalMinimumInteractiveComponentEnforcement 를 이용해서 최소 크기를

    완화할 수 있어요 // 예시 코드 CompositionLocalProvider( LocalMinimumInteractiveComponentEnforcement provides false ) { // UI 처리 }
  28. ❏ 그래픽 요소들에는 콘텐츠 라벨 제공 ❏ 콘텐츠 라벨 :

    화면에 보이는 UI 를 설명하는 텍스트 ❏ contentDescription, hint, lableFor etc.. 포함해요 UI 요소 설명
  29. UI 요소 설명 ❏ android:labelFor ❏ 텍스트 레이블을 특정 UI

    요소에 연결해서 콘텐츠를 설명하는데 도움을 줍니다
  30. UI 요소 설명 <merge xmlns:android="http://schemas.android.com/apk/res/android"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:labelFor="@id/password_input"

    // EditText 의 ID 입력 android:text="비밀번호" /> <com.google.android.material.textfield.TextInputEditText android:id="@+id/password_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="특수문자 포함 적어도 8자 이상" android:inputType="textPassword" /> </merge>
  31. ❏ 시스템 UI 컴포넌트에는 Role(Button, TextView, Image etc) 이 설정

    되어 있기 때문에, 콘텐츠 설명에는 Role 을 포함할 필요가 없어요 ❏ 상태 변경를 지원하기 때문에, 접근성을 더욱 높여줘요 UI 요소 설명
  32. 기타 개선사항 ❏ 색상 이외의 단서 활용하기 ❏ 빨간색을 부정적,

    초록색을 긍정의 의미로 사용하면 시각적인 불편함(적록색맹)이 있는 분들은 이해하기 어려워요
  33. Accessibility Scanner ❏ 스냅샷, 녹화 검사를 제공해요 ❏ 검사 가능

    목록 ❏ 콘텐츠 설명이 없는 상호작용 가능한 그래픽 요소 ❏ 작은 터치, 텍스트 사이즈 ❏ 텍스트, 이미지 낮은 대비 ❏ SP 대신 DP 사용 ❏ etc...
  34. Espresso 테스트 @RunWith(AndroidJUnit4::class) class CounterInstrumentedTest { @Rule @JvmField var activityTestRule

    = ActivityScenarioRule(MainActivity::class.java) @Test fun testIncrement() { Espresso.onView(withId(R.id.add_button)).perform(ViewActions.click()) Espresso.onView(withId(R.id.countTV)).check(matches(withText("1"))) } companion object { @BeforeClass @JvmStatic fun enableAccessibilityChecks() { AccessibilityChecks.enable().setRunChecksFromRootView(true) } } }
  35. Espresso 테스트 @RunWith(AndroidJUnit4::class) class CounterInstrumentedTest { @Rule @JvmField var activityTestRule

    = ActivityScenarioRule(MainActivity::class.java) @Test fun testIncrement() { Espresso.onView(withId(R.id.add_button)).perform(ViewActions.click()) Espresso.onView(withId(R.id.countTV)).check(matches(withText("1"))) } companion object { @BeforeClass @JvmStatic fun enableAccessibilityChecks() { AccessibilityChecks.enable().setRunChecksFromRootView(true) } } }
  36. ❏ Suppress 도 가능 Espresso 테스트 fun enableAccessibilityChecks() { AccessibilityChecks.enable()

    .setRunChecksFromRootView(true) .setSuppressingResultMatcher( matchesCheckNames(`is`("TouchTargetSizeCheck")) ) }
  37. Espresso 테스트 ❏ Suppress 가능한 리스트 ❏ ATF(Accessibility Test Framework)’s

    AccessibilityCheckResultUtils.java private static final ImmutableBiMap<String, Class<?>> VIEW_CHECK_ALIASES = ImmutableBiMap.<String, Class<?>>builder() .put("ClickableSpanViewCheck", ClickableSpanCheck.class) .put("DuplicateClickableBoundsViewCheck", DuplicateClickableBoundsCheck.class) .put("DuplicateSpeakableTextViewHierarchyCheck", DuplicateSpeakableTextCheck.class) .put("EditableContentDescViewCheck", EditableContentDescCheck.class) .put("RedundantContentDescViewCheck", RedundantDescriptionCheck.class) .put("SpeakableTextPresentViewCheck", SpeakableTextPresentCheck.class) .put("TextContrastViewCheck", TextContrastCheck.class) .put("TouchTargetSizeViewCheck", TouchTargetSizeCheck.class) .buildOrThrow();
  38. Grouping ❏ 연관된 콘텐츠를 그룹화하여 효율적으로 정보를 전달할 수 있어요

    ❏ 중요한 UI 요소까지 포커스를 여러 번 불필요하게 이동하는 경우 ❏ 동일한 콘텐츠가 여러 번 포커스 되어 내용이 반복되는 문제 해결
  39. Grouping(ViewSystem) <RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:contentDescription="@string/settings_do_not_disturb"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerVertical="true" android:importantForAccessibility="no"

    // 혹은 android:screenReaderFocusable="false" android:text="@string/settings_do_not_disturb" /> <com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/switch" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_centerVertical="true" /> </RelativeLayout>
  40. Grouping(Compose) Box( modifier = Modifier .fillMaxWidth() .wrapContentHeight() .semantics { contentDescription

    = stringResource(id = R.string.settings_do_not_disturb) } ) { Text( text = stringResource(id = R.string.settings_do_not_disturb), modifier = Modifier .align(Alignment.CenterStart) .clearAndSetSemantics { } // 설정되어 있는 semantics 정보 제거 ) Switch( checked = false, onCheckedChange = {}, modifier = Modifier.align(Alignment.CenterEnd) ) }
  41. LiveRegion ❏ 스크린 리더 포커스 이동 없이 실시간으로 변화하는 UI

    요소를 설명하기 적합해요 ❏ 알림, 에러 상태, 채팅, 게임 스코어, 주식 가격 등 ❏ 네비게이션 - 전방에 과속 카메라 알림
  42. LiveRegion ❏ Polite ❏ TalkBack 이 다른 내용을 읽고 있을

    때는 중단하지 않고, 현재 읽기를 완료한 후에 알림을 읽어요 ❏ Assertive ❏ TalkBack 이 즉시 현재 읽기를 중단하고, 알림을 읽어요
  43. LiveRegion(Compose) @Composable fun ErrorContainer(errorText: String) { Row( modifier = Modifier

    .fillMaxWidth() .semantics { liveRegion = LiveRegionMode.Polite } // or LiveRegionMode.Assertive ) { Image( imageVector = Icons.Default.Info, contentDescription = null, // 중요하지 않은 그래픽 요소 ) Text( text = errorText, color = Color.Red, ) } }
  44. CustomView ❏ Canvas 에 직접 그리는 경우, 접근성 처리는 개발자

    몫이에요. ❏ 오픈소스 사용시 주의해야해요.
  45. CustomView ❏ 스크린 리더에 포커스 되도록 처리하기 init { //

    초기화 로직들.. setAccessibility() } private fun setAccessibility() { isFocusable = true isClickable = true importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES }
  46. CustomView ❏ Accessibility Action 정의하기 override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo?) {

    super.onInitializeAccessibilityNodeInfo(info) info?.addAction(ACTION_SET_PROGRESS) // Progress 와 연관 있는 Action 을 설정해요 val rangeInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { AccessibilityNodeInfo.RangeInfo( AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT, min, max, value ) } else { AccessibilityNodeInfo.RangeInfo.obtain( AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT, min, max, value ) } info?.rangeInfo = rangeInfo }
  47. CustomView ❏ Accessibility Action 정의하기 override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo?) {

    super.onInitializeAccessibilityNodeInfo(info) info?.addAction(ACTION_SET_PROGRESS) // Progress 와 연관 있는 Action 을 설정해요 val rangeInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { AccessibilityNodeInfo.RangeInfo( AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT, min, max, value ) } else { AccessibilityNodeInfo.RangeInfo.obtain( AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT, min, max, value ) } // Progress 의 min, max 그리고 현재 값을 설정해요 info?.rangeInfo = rangeInfo }
  48. CustomView ❏ Accessibility Action 추가하기 override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo?) {

    super.onInitializeAccessibilityNodeInfo(info) // 이전 코드 생략 info?.rangeInfo = rangeInfo if (isEnabled) { info?.addAction( AccessibilityNodeInfo.AccessibilityAction( AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, "Increase" ) ) info?.addAction( AccessibilityNodeInfo.AccessibilityAction( AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD, "Decrease" ) ) } // 접근성 노드에 스와이프 액션 추가 }
  49. CustomView ❏ Accessibility Action 처리하기 override fun performAccessibilityAction(action: Int, arguments:

    Bundle?): Boolean { if (!isEnabled) return false when (action) { AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> { // 추가한 액션인 경우 incrementSliderValue() announceForAccessibility(newValue.toString()) return true } AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> { // 추가한 액션인 경우 decrementSliderValue() announceForAccessibility(newValue.toString()) return true } } return super.performAccessibilityAction(action, arguments) }
  50. CustomView ❏ Accessibility Action 처리하기 override fun performAccessibilityAction(action: Int, arguments:

    Bundle?): Boolean { if (!isEnabled) return false when (action) { AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> { incrementSliderValue() // 슬라이더 값 증가 announceForAccessibility(newValue.toString()) return true } AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> { decrementSliderValue() // 슬라이더 값 감소 announceForAccessibility(newValue.toString()) return true } } return super.performAccessibilityAction(action, arguments) }
  51. CustomView ❏ Accessibility Action 처리하기 override fun performAccessibilityAction(action: Int, arguments:

    Bundle?): Boolean { if (!isEnabled) return false when (action) { AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> { incrementSliderValue() announceForAccessibility(newValue.toString()) // 스크린 리더를 통해 사용자에게 알려줘요. return true } AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> { decrementSliderValue() announceForAccessibility(newValue.toString()) // 스크린 리더를 통해 사용자에게 알려줘요. return true } } return super.performAccessibilityAction(action, arguments) }
  52. 오늘 우리는, ❏ 접근성의 중요성을 이해하고, 안드로이드 접근성 서비스에 대해

    알아봤어요 ❏ 접근성 가이드라인에는 텍스트 가시성 향상, 더 큰 터치 영역 사용, UI 요소 설명이 있어요 ❏ 접근성 검사 도구로는 Accessibility Scanner, Espresso 테스트가 있어요 ❏ 발생할 수 있는 문제는 커스텀 뷰의 접근성 처리, Grouping, LiveRegion 관련 문제가 있어요
  53. 마지막으로, 접근성 디자인은, 장애가 있는 사람뿐만 아니라 장애가 없는 사람에게도

    유익합니다 그리고 접근성은 장벽을 제거하고 모든 사람이 기술의 혜택을 누릴 수 있도록 하는 것입니다 Steve Ballmer