[접근성] 가로 스크롤 UI에서의 자동 선택 문제와 해결 방안
안녕하세요. 엔비전스입니다.
보이스오버(VoiceOver)나 톡백(TalkBack)과 같은 스크린 리더를 사용하여 모바일 화면을 탐색할 때, 사용자는 주로 '순차 탐색' 방식을 이용합니다. 순차 탐색이란 한 손가락을 좌우로 스와이프 하여 화면에 있는 초점 가능한 요소들을 순서대로 이동하며 콘텐츠를 읽는 방식을 말합니다.
일반적인 세로 스크롤 화면에서는 이러한 순차 탐색이 매우 자연스럽게 작동합니다. 사용자가 스와이프를 통해 화면의 가장자리를 넘어가면, 스크린 리더가 자동으로 화면을 아래로 스크롤 하여 끊김 없는 연속적인 탐색을 지원하기 때문입니다. 이는 문서 편집기에서 커서를 아래로 계속 내리면 보이지 않는 다음 줄이 나타나며 화면이 스크롤 되는 원리와 같습니다.
하지만, 가로 스크롤(Horizontal Scroll), 특히 '캐러셀(Carousel)'과 같은 UI에서는 접근성 이슈가 발생할 수 있습니다.
가로 스크롤에서의 접근성 딜레마
모바일 앱에서 흔히 볼 수 있는 '카드 선택 UI'를 예로 들어보겠습니다. 결제 수단을 선택하는 화면에서 사용자가 카드를 좌우로 넘기면, 해당 카드가 화면 중앙에 오는 즉시 '선택된 상태'로 변경되는 UI가 있다고 가정해 봅시다.
일반적인 시각 사용자(비장애인)의 경우, 스와이프를 통해 원하는 카드를 찾은 뒤 해당 카드에서 멈춰 선택을 확정하고, 이후 '확인'이나 '완료' 버튼을 눌러 다음 단계로 진행하는 것이 자연스러운 흐름입니다.
하지만 보이스오버 사용자의 경험은 이와 다르게 흘러갑니다.
- 사용자가 다음 카드를 탐색하기 위해 한 손가락으로 오른쪽 스와이프(순차 탐색)를 합니다.
- 초점이 다음 카드로 이동함과 동시에 가로 스크롤이 발생합니다.
- 앱의 로직에 따라 초점이 맞춰진 카드가 자동으로 '선택'되어 버립니다.
이러한 구조에서는 사용자가 단순히 다음 항목이 무엇인지 확인하려고 해도, 탐색을 하는 즉시 선택 값이 변경되는 문제가 발생합니다. 즉, 사용자가 중간에 있는 특정 카드를 선택하고 싶어도, 목록을 끝까지 탐색하다 보면 의도치 않게 계속해서 선택이 바뀌게 되고, 결국 강제로 리스트의 가장 마지막 카드만 선택된 상태에 놓이게 됩니다. 이는 명백히 사용자의 의도를 방해하는 경험입니다.
해결의 핵심 원리: 스크롤 영역의 그룹화와 '조절 가능' 속성
이 문제를 해결하기 위해서는 스크롤 탐색 동작과 선택 동작을 분리해야 합니다. 이를 위해 UIKit이나 SwiftUI 등 플랫폼에 관계없이 적용되는 공통적인 접근성 모델은 다음과 같습니다.
1. 개별 요소가 아닌 '전체 컨테이너'에 초점 맞추기
개별 카드 하나하나에 초점을 주는 대신, 스크롤 뷰 전체(컨테이너)를 하나의 접근성 요소로 그룹화합니다. 이렇게 하면 사용자가 좌우 스와이프를 할 때 개별 카드로 초점이 이동하지 않고, 컨테이너 전체가 하나의 덩어리로 인식됩니다.
2. '조절 가능(Adjustable)' 특성 부여
그룹화된 컨테이너에 '조절 가능(Adjustable)' 특성을 부여합니다. 이 속성을 적용하면 스크린 리더는 해당 요소를 슬라이더(Slider)와 같은 컨트롤로 인식하게 됩니다.
이 상태에서는 '한 손가락 위/아래 쓸기' 제스처가 활성화됩니다.
- 좌우 스와이프: 다음/이전 UI 요소로 이동 (컨테이너 밖으로 이동)
- 위아래 스와이프: 컨테이너 내부의 값을 변경 (카드 선택 변경)
※ 로터 동작 참고: 보이스오버의 '항목 기반 로터 변경' 설정이 켜져 있다면 슬라이더에 초점이 갔을 때 자동으로 '조절' 모드로 변경됩니다. 하지만 설정이 꺼져 있는 경우에는 사용자가 두 손가락을 시계 또는 반시계 방향으로 회전하여 로터를 '조절(Adjustable)' 항목에 맞춘 뒤 위 또는 아래 제스처를 사용해야 합니다.
3. 접근성 정보(Label & Value) 제공
컨테이너가 초점을 받았을 때 사용자에게 정확한 정보를 전달하기 위해 다음 속성을 설정해야 합니다.
- accessibilityLabel (레이블): 이 컨트롤이 무엇을 조절하는지 명시합니다. (예: "결제 카드 선택")
- accessibilityValue (값): 현재 선택되어 화면에 보이는 카드의 정보를 실시간으로 알려줍니다. (예: "신한카드 1234")
중요: accessibilityValue를 설정할 때는 화면에 시각적으로 표시된 텍스트 정보가 누락되지 않도록 주의해야 합니다. 예를 들어 화면에 카드 이름뿐만 아니라 카드 번호 뒷자리나 유효기간 등이 함께 표시되어 있다면, accessibilityValue에도 해당 내용("신한카드 1234")을 모두 포함해야 사용자가 동일한 정보를 얻을 수 있습니다.
결과적으로 보이스오버 사용자는 "결제 카드 선택, 신한카드 1234, 조절 가능"이라는 음성 피드백을 듣게 되며, 위아래 제스처를 통해 안전하게 카드를 변경할 수 있습니다.
플랫폼별 구현 방법
위에서 설명한 원리를 각 플랫폼에서 구현하는 방법은 다음과 같습니다. 실제 동작하는 UI 구성을 위해 필요한 스크롤 설정 코드까지 포함된 전체 예제입니다.
UI 킷 (UIKit)
UIKit에서는 접근성 로직을 처리하는 컨테이너 뷰(Container View)를 별도로 만들고, 그 안에 컬렉션 뷰(UICollectionView)를 배치하는 패턴이 효율적입니다.
- AccessibleCarouselContainerView: UICollectionView를 감싸는 부모 뷰입니다. 이곳에서 accessibilityTraits = .adjustable을 설정하고, 위/아래 제스처 시 인덱스를 변경하는 로직을 담당합니다.
- AccessibleCarouselViewController: 실제 UICollectionView의 레이아웃(FlowLayout)을 설정하고, 컨테이너 뷰의 인덱스 변경 이벤트에 맞춰 스크롤을 이동시킵니다.
구현 예제:
import UIKit
// 데이터 모델
struct PaymentCard {
let cardName: String
let cardNumber: String
}
// 1. 접근성 로직을 담당하는 컨테이너 뷰
class AccessibleCarouselContainerView: UIView {
var cards: [PaymentCard] = []
// 현재 선택된 인덱스가 변경되면 값 업데이트 및 콜백 실행
var currentIndex: Int = 0 {
didSet {
updateAccessibilityValue()
onIndexChanged?(currentIndex)
}
}
// 인덱스 변경 시 외부(ViewController)로 알림
var onIndexChanged: ((Int) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
setupAccessibility()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupAccessibility()
}
private func setupAccessibility() {
// 컨테이너 자체를 하나의 접근성 요소로 설정
// 상위 뷰가 true이므로, 하위 뷰(컬렉션 뷰 및 셀)는 자동으로 접근성에서 제외됨
isAccessibilityElement = true
accessibilityLabel = "결제 카드 선택"
accessibilityTraits = .adjustable
}
func updateAccessibilityValue() {
guard cards.indices.contains(currentIndex) else { return }
let card = cards[currentIndex]
// 화면에 보이는 모든 텍스트 정보를 포함
accessibilityValue = "\(card.cardName) \(card.cardNumber)"
}
// 한 손가락 위로 쓸기: 다음 카드로 이동
override func accessibilityIncrement() {
guard currentIndex < cards.count - 1 else { return }
currentIndex += 1
}
// 한 손가락 아래로 쓸기: 이전 카드로 이동
override func accessibilityDecrement() {
guard currentIndex > 0 else { return }
currentIndex -= 1
}
}
// 2. 화면 구성 및 컬렉션 뷰 설정을 담당하는 ViewController
class AccessibleCarouselViewController: UIViewController {
private let cards: [PaymentCard] = [
PaymentCard(cardName: "삼성카드", cardNumber: "**** 1234"),
PaymentCard(cardName: "현대카드", cardNumber: "**** 5678"),
PaymentCard(cardName: "신한카드", cardNumber: "**** 9012")
]
private var carouselContainer: AccessibleCarouselContainerView!
private var collectionView: UICollectionView!
private let cellIdentifier = "AccessibleCardCell"
override func viewDidLoad() {
super.viewDidLoad()
setupCarouselContainer()
setupCollectionView()
}
private func setupCarouselContainer() {
// 컨테이너 뷰 초기화 및 데이터 주입
carouselContainer = AccessibleCarouselContainerView()
carouselContainer.translatesAutoresizingMaskIntoConstraints = false
carouselContainer.cards = cards
carouselContainer.updateAccessibilityValue()
// 접근성 액션으로 인덱스가 변경되었을 때, 실제 컬렉션 뷰 스크롤 이동
carouselContainer.onIndexChanged = { [weak self] index in
self?.scrollToIndex(index, animated: true)
}
view.addSubview(carouselContainer)
// 오토레이아웃 설정 (높이 180, 중앙 배치)
NSLayoutConstraint.activate([
carouselContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
carouselContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
carouselContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor),
carouselContainer.heightAnchor.constraint(equalToConstant: 180)
])
}
private func setupCollectionView() {
// 가로 스크롤을 위한 FlowLayout 설정
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 16
layout.itemSize = CGSize(width: view.bounds.width * 0.7, height: 160) // 카드 크기 설정
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .clear
collectionView.showsHorizontalScrollIndicator = false
collectionView.register(AccessibleCardCell.self, forCellWithReuseIdentifier: cellIdentifier)
collectionView.dataSource = self
carouselContainer.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.edgesAnchor.constraint(equalTo: carouselContainer.edgesAnchor)
])
}
// 인덱스에 맞춰 컬렉션 뷰 스크롤 이동
private func scrollToIndex(_ index: Int, animated: Bool) {
guard let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return }
let cellWidth = layout.itemSize.width + layout.minimumLineSpacing
let inset = (view.bounds.width - layout.itemSize.width) / 2 // 중앙 정렬을 위한 인셋 계산
// 컬렉션 뷰 contentInset 설정 (양옆 여백)
collectionView.contentInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
let offsetX = CGFloat(index) * cellWidth - inset
collectionView.setContentOffset(CGPoint(x: offsetX, y: 0), animated: animated)
}
}
extension AccessibleCarouselViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return cards.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as! AccessibleCardCell
let card = cards[indexPath.item]
// 셀 내부 UI 구성 (레이블 설정 등)
cell.configure(cardName: card.cardName, cardNumber: card.cardNumber)
return cell
}
}
// 셀 클래스
class AccessibleCardCell: UICollectionViewCell {
// ... UI 요소 선언 (Label 등) ...
override init(frame: CGRect) {
super.init(frame: frame)
// 상위 컨테이너(AccessibleCarouselContainerView)가 접근성 요소이므로,
// 하위 뷰인 셀은 자동으로 접근성에서 제외됩니다. 별도 설정 불필요.
}
required init?(coder: NSCoder) { fatalError() }
func configure(cardName: String, cardNumber: String) {
// UI 업데이트
}
}
SwiftUI
SwiftUI (iOS 17+)에서는 scrollTargetBehavior와 .scrollPosition을 활용하여 페이징(Paging)과 유사한 스냅 효과를 쉽게 구현할 수 있습니다. 여기에 접근성 수정자를 추가하여 로직을 완성합니다.
- 스크롤 구성: ScrollView와 LazyHStack을 사용하고, .containerRelativeFrame을 사용하여 카드 너비를 설정합니다.
- 접근성 그룹화: .accessibilityElement(children: .ignore)를 사용하여 스크롤 뷰 전체를 그룹화합니다.
- 액션 및 동기화: .accessibilityAdjustableAction으로 인덱스를 변경하면, 바인딩된 currentIndex에 의해 .scrollPosition이 반응하여 화면이 자동으로 스크롤 됩니다.
구현 예제:
import SwiftUI
struct CardData: Identifiable {
let id = UUID()
let cardName: String
let cardNumber: String
}
struct SwiftUICarouselView: View {
// 샘플 데이터
let sampleCards: [CardData] = [
CardData(cardName: "삼성카드", cardNumber: "**** 1234"),
CardData(cardName: "현대카드", cardNumber: "**** 5678"),
CardData(cardName: "신한카드", cardNumber: "**** 9012")
]
@State private var currentIndex: Int = 0
var body: some View {
VStack {
// 가로 스크롤 캐러셀
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(Array(sampleCards.enumerated()), id: \.element.id) { index, card in
CardView(cardName: card.cardName, cardNumber: card.cardNumber)
// 화면 너비에 맞춰 카드 크기 조정 (iOS 17+)
.containerRelativeFrame(.horizontal, count: 1, spacing: 16)
// 스크롤 시각 효과 (선택사항)
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.7)
.scaleEffect(phase.isIdentity ? 1 : 0.9)
}
.id(index) // 스크롤 위치 식별자
}
}
.scrollTargetLayout() // 스크롤 타겟 레이아웃 설정
}
// 뷰 단위로 스냅되도록 설정 (iOS 17+)
.scrollTargetBehavior(.viewAligned)
// currentIndex와 스크롤 위치 동기화
.scrollPosition(id: Binding(
get: { currentIndex },
set: { if let newValue = $0 { currentIndex = newValue } }
))
.contentMargins(.horizontal, 40, for: .scrollContent) // 좌우 여백
// --- 접근성 코드 시작 ---
// 1. 내부 요소(개별 카드) 무시하고 그룹화
.accessibilityElement(children: .ignore)
// 2. 접근성 레이블 및 값 설정
.accessibilityLabel("결제 카드 선택")
.accessibilityValue("\(sampleCards[currentIndex].cardName) \(sampleCards[currentIndex].cardNumber)")
// 3. 조절 가능 특성 추가 (SwiftUI 방식)
.accessibilityAddTraits(.adjustable)
// 4. 위/아래 쓸기 액션 정의
.accessibilityAdjustableAction { direction in
withAnimation {
switch direction {
case .increment: // 위로 쓸기 (다음 카드)
if currentIndex < sampleCards.count - 1 {
currentIndex += 1
}
case .decrement: // 아래로 쓸기 (이전 카드)
if currentIndex > 0 {
currentIndex -= 1
}
@unknown default:
break
}
}
}
// --- 접근성 코드 끝 ---
}
}
}
// 간단한 카드 뷰 컴포넌트
struct CardView: View {
let cardName: String
let cardNumber: String
var body: some View {
VStack(alignment: .leading) {
Text(cardName)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
Spacer()
Text(cardNumber)
.font(.body)
.foregroundColor(.white.opacity(0.8))
}
.padding(20)
.frame(height: 160)
.background(Color.blue)
.cornerRadius(12)
}
}
데모 앱 소스 코드 및 체험
글로 설명한 내용을 실제 기기에서 직접 체험해 보실 수 있도록 데모 앱 소스 코드를 깃허브에 공개해 두었습니다. 아래 링크를 통해 프로젝트를 다운로드하여 아이폰에 직접 빌드해 보시기를 권장합니다.
앱을 실행하면 '가로 스크롤 캐러셀 데모' 항목이 준비되어 있으며, 각각 UIKit 데모와 SwiftUI 데모를 제공합니다.
각 데모 페이지에 진입하면 '접근성 미적용(Before)'과 '접근성 적용(After)' 탭이 나뉘어 있습니다. 보이스오버(VoiceOver)를 켠 상태에서 두 환경을 번갈아 체험해 보시면, 접근성 적용 전후의 사용자 경험 차이를 명확하게 느끼실 수 있습니다.
마치며
지금까지 가로 스크롤 UI에서 초점 이동 시 콘텐츠가 자동으로 선택되어 발생하는 접근성 문제를 해결하는 방법을 살펴보았습니다.
핵심은 '탐색'과 '값 조절'의 제스처를 분리하는 것입니다. 플랫폼에 상관없이 컨테이너를 그룹화하고 Adjustable 속성을 활용한다면, 시각 장애인 사용자에게도 훨씬 쾌적하고 정확한 사용 경험을 제공할 수 있습니다.
다음에 더 유익한 아티클로 찾아뵙겠습니다. 감사합니다.