이전 화면 복귀 시 접근성 초점 불일치 해결하기 3부 - Android View System과 Jetpack Compose
안녕하세요. 엔비전스입니다. 지난 2부에서는 iOS SwiftUI 환경에서 화면 전환 시 보이스오버(VoiceOver) 초점을 복원하는 방법을 살펴보았습니다. NavigationStack 기반의 독립 화면 구조와 ZStack 기반의 단일 화면 구조를 나누어 보고, @AccessibilityFocusState와 AccessibilityNotification을 어떻게 조합해야 하는지 정리했습니다.
이번 3부에서는 같은 문제를 Android 환경에서 다룹니다. Android는 전통적인 View System과 선언형 UI 프레임워크인 Jetpack Compose로 나누어 살펴보겠습니다. iOS에서와 마찬가지로 핵심 관점은 단순합니다. 사용자가 상세 화면으로 이동하기 전의 맥락을 기록하고, 목록으로 돌아온 뒤 현재 목록에 존재하는 적절한 대상으로 초점을 되돌리는 것입니다.
Android View System에서는 화면 구성 방식에 따라 시스템 기본 동작만으로 충분한 경우가 있고, 개발자가 명시적으로 접근성 초점을 요청해야 하는 경우가 있습니다. 반면 Jetpack Compose에서는 독립 화면 구조와 단일 화면 구조 모두에서 명시적인 초점 복원 처리가 필요합니다.
Android에서 사용하는 접근성 초점 API 알아보기
본격적인 구현에 앞서, Android 환경에서 접근성 초점을 복원할 때 사용하는 핵심 API를 먼저 정리하겠습니다.
1. View System: ACTION_ACCESSIBILITY_FOCUS
View System에서는 특정 View에 접근성 초점을 보내기 위해 performAccessibilityAction()을 사용할 수 있습니다. 이때 전달하는 액션이 AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS입니다.
recyclerView.findViewHolderForAdapterPosition(position)
?.itemView
?.performAccessibilityAction(
AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS,
null
)
이 코드는 RecyclerView에서 특정 위치의 ViewHolder를 찾은 뒤, 해당 itemView에 접근성 초점을 요청합니다. 토크백(TalkBack)이 실행 중일 때 사용자가 다시 목록으로 돌아왔음을 자연스럽게 이어가기 위해 사용할 수 있는 명령형 접근입니다.
다만 여기서 중요한 점은 대상 View가 실제로 화면에 존재해야 한다는 것입니다. RecyclerView는 화면 밖 항목의 View를 아직 만들지 않았을 수 있으므로, 먼저 대상 위치로 스크롤한 뒤 일정 시간 뒤에 접근성 초점을 요청하는 흐름이 필요합니다.
recyclerView.scrollToPosition(position)
recyclerView.postDelayed(
{
recyclerView.findViewHolderForAdapterPosition(position)
?.itemView
?.performAccessibilityAction(
AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS,
null
)
},
1000L
)
여기서 1초 딜레이를 두는 이유는 목록 데이터 반영, RecyclerView 레이아웃, ViewHolder 생성이 끝난 뒤 초점을 요청하기 위해서입니다. 너무 빠르게 호출하면 대상 View가 아직 준비되지 않아 초점 이동이 실패할 수 있습니다.
2. Jetpack Compose: FocusRequester
Jetpack Compose에서는 View System의 ACTION_ACCESSIBILITY_FOCUS처럼 접근성 초점만 별도로 직접 보내는 API가 지원되지 않습니다. 따라서 Compose에서는 FocusRequester.requestFocus()를 사용하여 키보드/입력 포커스 체계 안에서 포커스 가능한 컴포저블로 초점을 보내고, 스크린 리더 초점도 그 흐름을 따라오도록 처리하는 방식이 현실적인 대응이 됩니다.
웹에서 tabindex="-1" 등을 사용해 프로그래밍적으로 키보드 포커스를 받을 수 있는 대상으로 만든 뒤, 스크린 리더의 읽기 흐름을 맞추는 방식과 비슷한 관점으로 이해할 수 있습니다. 물론 Android Compose와 웹의 포커스 모델이 완전히 같다는 뜻은 아닙니다. 접근성 초점 전용 API가 명확하지 않은 상황에서, 포커스 가능한 대상으로 입력 포커스를 이동시켜 스크린 리더 흐름도 함께 맞추는 실용적인 접근이라는 의미입니다.
Compose에서 기본적인 사용 패턴은 다음과 같습니다.
val focusRequester = remember { FocusRequester() }
ListItem(
modifier = Modifier
.focusRequester(focusRequester)
.focusable()
.clickable { onPostClick(post) },
headlineContent = {
Text(post.title)
}
)
// 필요한 시점에 초점 요청
focusRequester.requestFocus()
여기서 Modifier.focusRequester는 초점을 요청할 대상을 연결하고, Modifier.focusable은 해당 컴포저블이 실제로 포커스를 받을 수 있도록 만듭니다. 둘 중 하나만으로는 의도한 초점 복원이 안정적으로 동작하기 어렵습니다.
아키텍처별 초점 복원 전략
Android에서도 화면 전환 아키텍처에 따라 접근성 초점 복원 전략이 달라집니다. 여기서는 View System의 독립 화면 Activity 구조, View System의 단일 화면 Fragment 구조, 그리고 Jetpack Compose 구조를 나누어 살펴보겠습니다.
1. View System 독립 화면 구조
먼저 View System에서 목록과 상세가 각각 독립된 Activity로 구성된 경우입니다. 게시글 목록 Activity에서 상세 Activity를 startActivity()로 열고, 상세 화면에서 뒤로가기 또는 finish()로 목록 Activity로 돌아오는 구조입니다.
이 구조에서는 별도의 초점 복원 처리를 하지 않아도 됩니다. Activity 전환은 Android 시스템이 화면 이동으로 인지하는 전형적인 구조이기 때문에, 상세 화면에서 목록으로 돌아왔을 때 토크백 초점이 이전 맥락을 비교적 자연스럽게 따라갑니다.
목록에서 상세로 진입하는 코드는 단순합니다.
adapter = PostAdapter { post ->
startActivity(ViewSeparateDetailActivity.intent(this, post.id))
}
상세 화면에서 뒤로가기를 누르거나 삭제가 완료되면 Activity를 종료합니다.
backButton = Button(this).apply {
text = "뒤로가기"
contentDescription = "뒤로가기"
applyButtonLayout()
setOnClickListener { finish() }
}
private fun render(state: PostDetailUiState) {
if (state.deleted) {
finish()
return
}
}
여기서 주목할 점은 삭제 후 복귀 상황입니다. 사용자가 상세 화면에서 게시글을 삭제하면, 상세 Activity는 finish()되고 목록 Activity가 다시 표시됩니다. 이때 원래 초점이 있던 게시글은 목록에서 사라졌지만, View System 독립 화면 구조에서는 시스템 기본 복귀 흐름에 따라 이전 또는 다음 요소로 초점이 자연스럽게 이동합니다.
따라서 이 구조에서는 선택한 게시글 ID, 이웃 게시글 ID, 삭제 여부를 별도로 저장하거나 ACTION_ACCESSIBILITY_FOCUS를 추가로 호출할 필요도 없습니다.
2. View System 단일 화면 구조
다음은 하나의 Activity 안에서 Fragment를 교체하여 목록과 상세를 보여주는 단일 화면 구조입니다. 겉으로는 화면이 바뀌지만, 시스템 입장에서는 같은 Activity 내부에서 View 계층이 교체되는 것입니다.
이 구조에서는 상세에서 목록으로 돌아올 때 접근성 초점을 명시적으로 복귀시켜야 합니다. Fragment popBackStack()만으로는 사용자가 어느 게시글에서 상세로 들어갔는지, 삭제 후 어느 항목으로 이어가야 하는지 충분히 알기 어렵기 때문입니다.
핵심 흐름은 다음과 같습니다.
1단계: 게시글 진입 시 복귀 맥락 저장
사용자가 목록 항목을 선택하면 선택한 postId와 함께 다음 게시글 ID, 이전 게시글 ID를 저장합니다. 삭제 여부는 처음에는 false로 둡니다.
adapter = PostAdapter { post ->
val activity = requireActivity() as ViewSingleActivity
activity.rememberFocusRestoreTarget(post.id, adapter.currentList)
activity.showDetail(post.id)
}
Activity에서는 복귀에 필요한 정보를 FocusRestoreRequest 형태로 보관합니다.
fun rememberFocusRestoreTarget(selectedPostId: Int, posts: List<Post>) {
val selectedIndex = posts.indexOfFirst { it.id == selectedPostId }
focusRestoreRequest = FocusRestoreRequest(
selectedPostId = selectedPostId,
nextPostId = if (selectedIndex == -1) null else posts.getOrNull(selectedIndex + 1)?.id,
previousPostId = if (selectedIndex == -1) null else posts.getOrNull(selectedIndex - 1)?.id
)
}
private data class FocusRestoreRequest(
val selectedPostId: Int,
val nextPostId: Int?,
val previousPostId: Int?,
val deleted: Boolean = false
)
이 정보를 저장해 두는 이유는 목록으로 돌아왔을 때 원래 게시글이 그대로 있는지, 삭제되었는지, 또는 새로고침 결과로 사라졌는지 판단하기 위해서입니다.
2단계: 상세에서 삭제 여부 기록
상세 화면에서 게시글 삭제가 완료되면 목록으로 돌아가기 전에 삭제 여부를 기록합니다.
private fun render(state: PostDetailUiState) {
if (state.deleted) {
(requireActivity() as ViewSingleActivity).markFocusRestoreTargetDeleted(postId)
parentFragmentManager.popBackStack()
return
}
}
Activity에서는 저장된 요청의 selectedPostId와 삭제된 postId가 일치할 때 deleted 값을 true로 바꿉니다.
fun markFocusRestoreTargetDeleted(postId: Int) {
val request = focusRestoreRequest ?: return
if (request.selectedPostId == postId) {
focusRestoreRequest = request.copy(deleted = true)
}
}
3단계: 목록 재로딩 완료 후 대상 계산
목록 Fragment는 onResume()에서 목록을 다시 불러옵니다. 이때 복귀할 초점 대상이 남아 있으면, 로딩이 실제로 시작되고 끝난 뒤 초점 복원을 시도합니다.
override fun onResume() {
super.onResume()
val activity = requireActivity() as ViewSingleActivity
if (activity.hasPendingFocusRestoreTarget()) {
restoreAfterReload = true
sawReloadLoading = false
}
viewModel.loadPosts()
}
목록 로딩이 끝나면 현재 목록을 기준으로 초점 대상을 다시 계산합니다.
fun consumeFocusRestoreTarget(posts: List<Post>): Int? {
val request = focusRestoreRequest ?: return null
focusRestoreRequest = null
if (!request.deleted && posts.any { it.id == request.selectedPostId }) {
return request.selectedPostId
}
return listOfNotNull(request.nextPostId, request.previousPostId)
.firstOrNull { targetId -> posts.any { it.id == targetId } }
}
판단 기준은 삭제되지 않았고 원래 선택한 게시글이 현재 목록에 남아 있다면 그 게시글로 돌아갑니다. 삭제되었거나 원래 게시글이 사라졌다면 다음 게시글을 먼저 찾고, 다음 게시글도 없으면 이전 게시글을 찾습니다.
여기서 다음 항목을 우선하는 이유는 사용자의 읽기 흐름을 이어가기 위해서입니다. 목록을 위에서 아래로 탐색하다가 상세에 들어갔다면, 삭제 후에는 같은 위치에 가까운 다음 항목으로 이어지는 편이 자연스럽습니다. 단, 다음 항목이 없다면 이전 항목이 가장 가까운 대체 대상이 됩니다.
4단계: RecyclerView 항목으로 접근성 초점 요청
대상 postId가 결정되면 RecyclerView에서 해당 위치를 찾고, 그 위치로 스크롤한 뒤 약 1초 후 접근성 초점을 요청합니다.
private fun restoreAccessibilityFocus(postId: Int, posts: List<Post>) {
val position = posts.indexOfFirst { it.id == postId }
if (position == -1) return
recyclerView.scrollToPosition(position)
recyclerView.postDelayed(
{
recyclerView.findViewHolderForAdapterPosition(position)
?.itemView
?.performAccessibilityAction(
AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS,
null
)
},
1000L
)
}
이 구현에서 핵심은 위에서 언급한 바와 깉이 "돌아온 직후 바로 초점을 보내지 않는다"는 점입니다. 목록 재로딩이 끝나고, 어댑터에 새 목록이 반영되고, RecyclerView가 대상 항목을 실제 View로 만든 뒤에 접근성 초점을 요청해야 합니다. 그래서 submitList() 완료 콜백 이후 restoreAccessibilityFocus()를 호출하고, 내부에서 다시 postDelayed()로 짧은 여유를 둡니다.
3. Jetpack Compose 독립 화면과 단일 화면 구조
Jetpack Compose에서는 독립 화면 구조와 단일 화면 구조 모두 명시적인 초점 복귀가 필요합니다. Activity를 나누어 구현했더라도 Compose 목록은 LazyColumn과 상태 기반 렌더링으로 다시 그려지며, 특정 게시글로 접근성 흐름을 정확히 되돌리려면 선택 맥락을 직접 보관하고 복원해야 합니다.
구현의 핵심 흐름은 다음과 같습니다.
1단계: ComposeFocusRestoreRequest로 복귀 맥락 저장
Compose에서도 View 단일 화면 구조와 같은 정보를 저장합니다. 선택한 게시글 ID, 다음 게시글 ID, 이전 게시글 ID, 삭제 여부를 하나의 상태 객체로 보관합니다.
data class ComposeFocusRestoreRequest(
val selectedPostId: Int,
val nextPostId: Int?,
val previousPostId: Int?,
val deleted: Boolean = false
) {
fun resolveTargetPostId(posts: List<Post>): Int? {
if (!deleted && posts.any { it.id == selectedPostId }) {
return selectedPostId
}
return listOfNotNull(nextPostId, previousPostId)
.firstOrNull { targetId -> posts.any { it.id == targetId } }
}
}
목록에서 게시글을 선택할 때 이 요청을 생성합니다.
private fun createFocusRestoreRequest(
selectedPostId: Int,
posts: List<Post>
): ComposeFocusRestoreRequest {
val selectedIndex = posts.indexOfFirst { it.id == selectedPostId }
return ComposeFocusRestoreRequest(
selectedPostId = selectedPostId,
nextPostId = if (selectedIndex == -1) null else posts.getOrNull(selectedIndex + 1)?.id,
previousPostId = if (selectedIndex == -1) null else posts.getOrNull(selectedIndex - 1)?.id
)
}
독립 Activity 구조에서는 상세 Activity에서 삭제 결과를 돌려받아 pendingFocusRestoreRequest의 deleted 값을 true로 바꿉니다. 단일 화면 구조에서는 상세 상태의 deleted 값을 감지해 같은 방식으로 pendingFocusRestoreRequest를 갱신한 뒤 목록 route로 돌아갑니다.
2단계: LazyColumn 항목에 stable key와 FocusRequester 연결
Compose에서 목록 항목으로 초점을 되돌리려면 각 게시글 ID와 FocusRequester를 안정적으로 연결해야 합니다. LazyColumn에는 stable key를 지정하고, post.id별로 FocusRequester를 보관합니다.
val listState = rememberLazyListState()
val focusRequesters = remember { mutableStateMapOf<Int, FocusRequester>() }
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState
) {
items(state.posts, key = { it.id }) { post ->
val focusRequester = focusRequesters.getOrPut(post.id) {
FocusRequester()
}
ListItem(
modifier = Modifier
.focusRequester(focusRequester)
.focusable()
.clickable { onPostClick(post) },
headlineContent = {
Text(
text = post.title,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
여기서 stable key가 중요합니다. LazyColumn이 항목을 재사용하더라도 post.id를 기준으로 항목의 정체성을 유지해야, 복귀 시 "몇 번째 셀"이 아니라 "어떤 게시글"로 초점을 되돌릴 수 있습니다.
3단계: 목록 재로딩 완료 후 scrollToItem()과 requestFocus() 호출
목록으로 돌아온 뒤에는 View System과 마찬가지로 바로 초점을 보내지 않습니다. 목록 로딩이 끝나고 에러가 없는 상태에서 현재 목록 기준의 대상 게시글을 계산합니다.
LaunchedEffect(
focusRestoreRequest,
state.isLoading,
state.errorMessage,
state.posts
) {
val request = focusRestoreRequest ?: return@LaunchedEffect
if (state.isLoading || state.errorMessage != null) return@LaunchedEffect
val targetPostId = request.resolveTargetPostId(state.posts)
?: run {
onFocusRestoreConsumed()
return@LaunchedEffect
}
val targetIndex = state.posts.indexOfFirst { it.id == targetPostId }
if (targetIndex != -1) {
listState.scrollToItem(targetIndex)
delay(1000L)
runCatching {
focusRequesters[targetPostId]?.requestFocus()
}
}
onFocusRestoreConsumed()
}
핵심은 scrollToItem(index)와 delay(1000L), 그리고 requestFocus()의 순서입니다. 먼저 LazyColumn을 대상 index로 이동시켜 항목이 composition될 수 있게 하고, 약 1초 뒤 FocusRequester.requestFocus()를 호출합니다.
여기서 다시 한 번 주목할 점은 Compose에서 호출하는 requestFocus()가 View System의 ACTION_ACCESSIBILITY_FOCUS와 같은 접근성 초점 전용 요청은 아니라는 점입니다. Compose에서는 포커스 가능한 컴포저블에 입력 포커스를 보내고, 토크백의 초점 흐름이 그 상태 변화를 따라오도록 구성합니다. 그래서 Modifier.focusRequester와 Modifier.focusable을 함께 지정하는 것이 중요합니다.
4단계: 독립 화면과 단일 화면의 차이
Compose 독립 화면과 단일 화면의 복원 방식은 대부분 같습니다. 차이는 "복귀를 어떻게 감지하느냐"에 있습니다.
독립 Activity 구조에서는 상세 Activity를 ActivityResultLauncher로 열고, 상세에서 삭제가 발생하면 결과값으로 삭제 여부를 전달받습니다. 목록 Activity의 onResume()에서는 목록을 다시 불러오고, pendingFocusRestoreRequest가 있으면 목록 재로딩 완료 후 초점 복원을 예약합니다.
detailLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (
result.resultCode == RESULT_OK &&
result.data?.getBooleanExtra(
ComposeSeparateDetailActivity.EXTRA_POST_DELETED,
false
) == true
) {
pendingFocusRestoreRequest = pendingFocusRestoreRequest?.copy(deleted = true)
}
}
override fun onResume() {
super.onResume()
viewModel.loadPosts()
if (pendingFocusRestoreRequest != null) {
restoreAfterListReloadState.value = true
sawListReloadState.value = false
}
}
단일 화면 구조에서는 route 상태가 Detail에서 List로 바뀌는 시점에 목록을 다시 불러오고, 재로딩이 끝난 뒤 focusRestoreRequest를 목록 화면에 전달합니다.
onBack = {
restoreAfterListReload = true
sawListReload = false
listViewModel.loadPosts()
route = Route.List
}
LaunchedEffect(detailState.deleted) {
if (detailState.deleted) {
pendingFocusRestoreRequest =
pendingFocusRestoreRequest?.copy(deleted = true)
restoreAfterListReload = true
sawListReload = false
listViewModel.loadPosts()
route = Route.List
}
}
즉, Compose에서는 독립 화면이든 단일 화면이든 "이전 맥락 저장 → 목록 재로딩 완료 확인 → 현재 목록 기준 대상 계산 → LazyColumn 스크롤 → FocusRequester로 초점 요청"이라는 흐름이 동일하게 적용됩니다.
View System과 Jetpack Compose의 접근 방식 비교
지금까지의 내용을 구조별로 정리하면 다음과 같습니다.
| 구분 | 대응 방식 | 초점 이동 방식 | 삭제 후 복귀 | 주요 특징 |
|---|---|---|---|---|
| View 독립 화면(Activity) | 별도 대응 없음 | 시스템 기본 복귀 흐름 사용 | 이전 또는 다음 요소로 자연스럽게 이동 | Activity 전환을 시스템이 화면 이동으로 처리 |
| View 단일 화면(Fragment) | 선택 맥락 저장 후 명시적 복원 | ACTION_ACCESSIBILITY_FOCUS | 다음 항목 우선, 없으면 이전 항목 | 목록 재로딩과 RecyclerView 레이아웃 완료 후 요청 |
| Compose 독립 화면(Activity) | 명시적 상태 저장과 복원 | FocusRequester.requestFocus() | 다음 항목 우선, 없으면 이전 항목 | ActivityResult와 onResume()을 통해 복귀 감지 |
| Compose 단일 화면(Route 상태) | 명시적 상태 저장과 복원 | FocusRequester.requestFocus() | 다음 항목 우선, 없으면 이전 항목 | route 상태 변화와 목록 재로딩 완료를 기준으로 복원 |
위에서 언급했듯 여기서 가장 큰 차이는 View 독립 화면 구조입니다. View System에서 Activity를 분리한 구조는 시스템 기본 복귀가 충분히 자연스럽기 때문에, 추가적인 초점 복원 로직을 넣지 않는 쪽이 더 단순하고 안정적입니다. 특히 상세에서 게시글을 삭제하고 돌아오는 경우에도 주변 요소로 초점이 이동하므로 별도 대응이 필요하지 않습니다.
반면 View 단일 화면 구조에서는 시스템이 내부 Fragment 전환을 독립된 화면 복귀로 충분히 처리해 주지 않습니다. 따라서 개발자가 이전 맥락을 저장하고, 목록이 다시 준비된 뒤 RecyclerView의 대상 항목에 ACTION_ACCESSIBILITY_FOCUS를 보내야 합니다.
Compose는 View System과 또 다릅니다. 독립 Activity 구조라 하더라도 LazyColumn과 상태 기반 렌더링 특성상 특정 항목으로 흐름을 되돌리려면 명시적인 상태 저장과 FocusRequester가 필요합니다. 또한 접근성 초점 전용 API를 직접 호출하는 방식이 아니므로, 포커스 가능한 컴포저블을 만들고 입력 포커스 요청을 통해 토크백 흐름을 맞추는 관점으로 접근해야 합니다.
지금까지 Android View System과 Jetpack Compose 환경에서 이전 화면으로 복귀할 때 접근성 초점을 유지하는 방법을 살펴보았습니다.
원칙은 iOS와 같습니다. 사용자가 어느 항목에서 상세 화면으로 들어갔는지 기록하고, 돌아온 뒤 현재 목록에 존재하는 가장 적절한 대상으로 초점을 복원해야 합니다. 삭제되지 않았다면 원래 항목으로, 삭제되었거나 사라졌다면 다음 항목으로, 다음 항목이 없다면 이전 항목으로 이어가면 됩니다.
다만 Android 안에서도 구현 방식은 프레임워크와 화면 구조에 따라 달라집니다. View 독립 화면 구조에서는 시스템 기본 복귀 흐름을 그대로 활용하고, View 단일 화면 구조에서는 ACTION_ACCESSIBILITY_FOCUS를 명시적으로 요청합니다. Jetpack Compose에서는 FocusRequester와 focusable을 사용해 포커스 가능한 컴포저블로 흐름을 되돌립니다.
결국 중요한 것은 비장애인 사용자와 동일한 플로우로 스크린 리더 사용자도 화면을 탐색하도록 만들어 주자는 것이 핵심입니다. 감사합니다.