첫 안드로이드 오픈소스 기여를 준비하며 예제 자료를 찾아보았지만, Kotlin으로 작성된 애니메이션 예제는 생각보다 많지 않았습니다. 네이티브 애니메이션에 접근성 요소를 반영한 코드 예제 역시 거의 없었습니다.
그래서 준비했습니다! 이번 글에서는 Kotlin으로 네이티브 '확장(expand)' 애니메이션을 작성해 보고, TalkBack을 사용하거나 글자 크기를 키운 사용자들을 어떻게 도울 수 있는지 살펴보겠습니다. 전체 코드는 예제 리포지토리에서 확인할 수 있으며, 하나의 액티비티 안에 애니메이션이 적용된 뷰가 담겨 있습니다. 이 글의 기반이 된 코드는 Calum Turner와 공동으로 작성했습니다.
안드로이드 접근성(a11y)
모든 안드로이드 기기에는 'TalkBack'이라는 스크린 리더가 기본으로 내장되어 있습니다. 기기 설정에서 켤 수 있으며, 처음 사용자를 위한 가이드도 함께 제공됩니다. 사용자는 제스처로 화면을 탐색하고, 포커스된 요소에 대한 설명을 음성으로 들을 수 있습니다. 이 기능이 없다면 시각 장애가 있는 많은 사용자에게 앱은 사실상 사용할 수 없는 것이 됩니다.
여기서 특히 중요한 것은 세 가지입니다. 올바른 요소가 포커스를 받을 수 있도록 하고, 각 요소에 적절한 설명을 제공하며, 뷰에 변화가 생겼을 때 이를 사용자에게 알려주는 것입니다.
같은 설정 메뉴에서는 기본 글꼴 크기도 조절할 수 있으며, 배율은 1.0부터 시작합니다. 뷰는 이러한 글꼴 크기 변경에 대응해야 하고, 어떤 배율에서도 모든 요소가 화면에 나타나며 정상적으로 동작해야 합니다.
레이아웃
레이아웃의 세부 스타일링은 이 예제에만 해당하는 부분이라 여기서 다루지 않겠지만, 접근성 관련 처리 몇 가지는 짚고 넘어갈 가치가 있습니다.
여기서는 두 가지 속성을 사용합니다: android:contentDescription과 android:importantForAccessibility.
contentDescription은 요소가 포커스를 받았을 때 읽어주는 텍스트입니다. 포커스를 받는 ImageView라면 이 속성은 필수적입니다. 설정하지 않으면 스크린 리더는 아무 의미 없는 'unlabelled'(라벨 없음)라는 문구만 사용자에게 읽어줍니다.
버튼이라면 기본적으로 '<설명> 버튼, 두 번 탭하여 활성화'라고 읽어주지만, 우리가 사용하는 ImageView 아이콘에는 이런 기본 동작이 없으므로 동작을 직접 명시해 주어야 합니다.
android:contentDescription="탭하여 인원 추가 정보 열기/닫기"
또한 importantForAccessibility:no를 사용해 '+' TextView의 포커스를 비활성화했습니다. 두 배지(badge) 아래의 텍스트가 이미 설명 역할을 하기 때문에, '+'까지 읽어주면 오히려 혼란만 가중시킬 수 있기 때문입니다.
이 두 가지 모두, 실제 기기에서 TalkBack을 켜고 직접 테스트해 보는 것이 화면 없이도 맥락이 자연스럽게 전달되는지 확인하는 가장 좋은 방법입니다.
확장 애니메이션
애니메이션은 'info' 아이콘을 탭하면 활성화되며, 상세 정보 섹션이 펼쳐지거나 접히도록 토글합니다.
애니메이션 로직에 집중할 수 있도록 모든 코드를 단일 액티비티 안에 작성하겠습니다. 실제 앱에서는 이런 뷰가 별도의 프래그먼트나 RecyclerView 안에 들어가는 경우가 많으므로, 그에 맞게 더 추상화된 코드 구조를 사용하게 됩니다.
리스너 설정하기
예제 액티비티의 onCreate에서 먼저 아이콘에 클릭 리스너를 설정하고, 토글할 뷰를 전달합니다.
infoIcon.setOnClickListener { toggleCardBody(root.personEntryBody) }
클래스 안에는 뷰가 현재 펼쳐져 있는지 추적하는 변수도 선언하고, 초기값은 닫힌 상태(false)로 설정합니다.
private var isToggled = false
확장 토글 애니메이션
레이아웃에서 personEntryBody의 초기 높이는 0dp로 설정되어 있습니다.
이 뷰를 펼치려면 새로 설정할 높이 값, 애니메이션 지속 시간, 그리고 애니메이션의 각 순간마다 가져야 할 높이를 알아야 합니다.
그다음 isToggled 값을 반전시키고, 다시 탭했을 때 반대 동작이 일어나도록 만들어야 합니다.
private fun toggleCardBody(body: View) {
body.measure(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
val maxHeight = body.measuredHeight + body.paddingTop + body.paddingBottom
val startHeight = if (isToggled) maxHeight else 0
val targetHeight = if (isToggled) 0 else maxHeight
val expandAnimator = ValueAnimator
.ofInt(startHeight, targetHeight)
.setDuration(200)
expandAnimator.addUpdateListener {
val value = it.animatedValue as Int
body.layoutParams.height = value
body.requestLayout()
}
expandAnimator.doOnEnd {
isToggled = !isToggled
}
expandAnimator.start()
}
뷰가 처음 그려질 때 높이가 0이므로, 새 크기를 계산하려면 레이아웃을 다시 측정(measure)해야 합니다.
안드로이드 뷰 레이아웃 공식 문서에서 설명하듯, measure()를 뷰에 할당된 레이아웃 파라미터와 함께 사용하면 info 아이콘이 탭될 때마다 레이아웃을 다시 측정할 수 있습니다.
최대 높이를 계산할 때는 측정된 높이에 위아래 패딩이 포함되지 않으므로, 이를 수동으로 더해 주어야 합니다.
isToggled 값에 따라 0에서 시작하는지, 펼쳐진 최대 높이에서 시작하는지 판단할 수 있고, 그에 따라 반대편 목표 높이도 결정됩니다.
ValueAnimator를 사용해 시작 값에서 목표 값까지 이동하도록 하고, 지속 시간을 밀리초(ms) 단위로 설정합니다. 이 지속 시간은 순수하게 UX 감각을 위한 수동 테스트를 바탕으로 정한 값입니다.
ValueAnimator
.ofInt(startHeight, targetHeight)
.setDuration(200)
업데이트 리스너를 통해 진행 시간과 높이를 연결하고, 매 업데이트마다 높이를 조정한 뒤 새 레이아웃 그리기를 요청합니다.
expandAnimator.addUpdateListener {
val value = it.animatedValue as Int
body.layoutParams.height = value
body.requestLayout()
}
expandAnimator.doOnEnd {
isToggled = !isToggled
}
expandAnimator.start()
Kotlin을 사용하고 있으므로 build.gradle에 androidx 라이브러리를 추가하면 doOnEnd 확장 함수의 이점을 활용할 수 있습니다. 덕분에 isToggled 변수를 아주 간결하게 반전시킬 수 있습니다.
마지막으로 애니메이션을 시작합니다! 이것만으로도 아이콘을 터치하면 본문이 펼쳐지고 접히는 뷰가 완성되었습니다!
더 부드러운 애니메이션
현재 상태로도 애니메이션은 기술적으로 잘 동작하지만, 인터폴레이터(interpolator)를 추가하면 움직임이 훨씬 자연스럽게 느껴집니다.
expandAnimator.interpolator = FastOutSlowInInterpolator()
접근성 한층 높이기
마지막으로 접근성 사용자를 돕기 위해 두 가지를 추가하겠습니다.
첫째, AccessibilityEvent를 활용해 탐색을 도울 수 있습니다.
expandAnimator.doOnEnd {
if (!isToggled) body.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
isToggled = !isToggled
}
이렇게 하면 애니메이션이 닫힌 상태에서 열린 상태로 전환될 때, 포커스가 본문의 첫 번째 항목(여기서는 설명 텍스트)으로 즉시 이동합니다. 레이아웃 단계에서 info 아이콘 동작에 대한 설명은 지정했지만, 시각적 힌트에 의존할 수 없는 사용자가 다음 항목으로 자연스럽게 이동할 수 있도록 이 부분을 코드가 대신 처리해 주는 것입니다.
둘째, 다양한 글꼴 크기를 지원하도록 만듭니다. measure()가 반환하는 측정 높이는 기기 접근성 설정의 글꼴 배율을 반영하지 않습니다. 따라서 배율이 큰 경우 설명 텍스트 하단이 잘려 보일 수 있습니다.
프로그램적으로 글꼴 배율을 읽어와 이에 맞춰 높이를 조절하면 해결할 수 있습니다. 글꼴 배율은 float 값을 반환할 수 있고 float는 레이아웃 높이로 사용할 수 없으므로, 정수로 변환해야 합니다.
val a11yFontScale = body.context.resources.configuration.fontScale
val maxHeight = ((body.measuredHeight + body.paddingTop + body.paddingBottom) * a11yFontScale).toInt()
완성!
드디어 최종 애니메이션이 완성되었습니다! 몇 줄의 코드만 추가했을 뿐인데도 접근성 커버리지가 크게 향상되었고, Kotlin과 Android 배지가 부드럽게 펼쳐지는 섹션까지 얻게 되었습니다.
읽어주셔서 감사합니다.
최근에 작성한 다른 글들도 확인해 보세요:
- CodeceptJS E2E 테스트 커스터마이징하기
- Jest와 Enzyme으로 React 테스트하기 II
유용한 추가 자료
- Joe Birch의 훌륭한 글 — Exploring KTX for Android (androidx)
- Android Accessibility Tutorial: Getting Started