아티클

SwiftUI와 접근성 1부: SwiftUI 소개 및 UIKit과 비교하기

엔비전스 접근성 2023-05-18 05:23:09

안녕하세요, 엔비전스입니다. 어느덧, iOS 버전도 16을 넘었고, 올해 초여름, 6월에 iOS17이 공개될 것 같습니다.

iOS 13부터 Apple은 선언형 UI 프로그래밍 기술인 SwiftUI라는 UIKit과 스토리보드를 쓰지 않는 새로운 UI 프로그래밍 프레임워크를 선보였습니다.

벌써 iOS 13과 함께 SwiftUI가 공개된 지도, 4년이 다 돼가네요. API가 충분히 성숙됨에 따라 기업에서도 SwiftUI를 사용하는 사례까 하나둘씩 보이기 시작했습니다. 이에 맞춰 2022년 널리 아티클 돌아보기 및 2023년 계획에서 SwiftUI 아티클을 예고했습니다. 오늘은 그 첫시간입니다.

SwiftUI에서는 일반적으로 컴포넌트를 Class로 정의하지 않고, View 프로토콜을 채텍하는 구조체(struct)를 사용합니다.

View를 상속하는 구조체는 반드시 some View를 반환하는 body 변수 프로퍼티를 받습니다. 모든 View에는 그에 맞는 모디파이어(Modifier) 메소드가 있고. 우리는 그것을 골라서 사용하여 원하는 요소를 조합하여 만들기만 하면 됩니다.

SwiftUI는 클래스 객체를 주로 사용하지 않기 때문에 기존 UIKit에서 접근하기에는 많이 다릅니다. 오늘은 한번 맛보기로 무엇이 다른지 알아보는 시간을 갖겠습니다.

SwiftUI의 접근성

SwiftUI의 모든 뷰는 그에 맞는 모디파이어를 가지고 있다고 말했습니다. 그중 View 프로토콜에서 받게 되는 공통된 모디파이어 중에는 접근성에 관련된 모디파이어도 있습니다. 한번 UIKit과 어떻게 다른지 한번 보도록 할까요?

가장 기본이 되는 레이블과 도움말, 값부터 봅시다.

레이블(Label) : accessibilityLabel

UIKit
//...생략
override func viewDidLoad(){
    //...생략

    let myBtn = UIButton()
    myBtn.text = "?"
    myBtn.accessibilityLabel = "Screen Reader Label"
    view.addSubView(myBtn)

}
SwiftUI
var body:some View {
    Button(label:{ Image(systemName:"questionmark.circle")})
    .accessibilityLabel("도움말")
}
스크린 리더 사용자에게 보이는 텍스트만으로는 정보가 부족하거나, 아이콘만 사용된 특정 뷰에 별도의 레이블을 젝공할 때 사용하는 accessibilityLabel입니다. 이름은 동일하게, accessibilityLabel을 사용하는 것을 볼 수 있습니다.

다른점은, UIKit은 컨트롤에 정의된 { get set } 프로퍼티를 사용하는 반면 SwiftUI는 메소드 형태로 된 모디파이어를 사용합니다.

SwiftUI에서는 위와 같이 모든 접근성 관련 기능은 메서드 형태 모디파이어로 구성되어 있습니다.

ViewModifier는 항상 View를 반환하고, 그 뷰는 ViewModifier로 인해 변환된 대상 뷰를 반환하는 것이지요. 메소드(이하 Modifier)가 View를 반환한다는 것은 메소드를 무한히 체이닝하여 새로운 모디파이어를 이어 사용할 수 있다는 의미입니다.

도움말(Hint) : accessibilityHint

힌트도 마찬가지로, 이름은 같습니다.

UIKit
//...생략
myBtn.accessibilityHint = "Double Tap to Open"
print(view.accessibilityHint ?? "") // Double Tap To Open
SwiftUI
var body:some View {
Button(label:{ Image(systemName:"questionmark.circle") })
.accessibilityLabel("도움말").accessibilityHint("이중탭하여 도움말 펼치기")
}

UIKit에서의 접근성 프로퍼티는 set 프로파티인 동시에, get 프로퍼티이기 때문에 가지고 있는 값을 따로 저장할 필요 없이 재활용할 수 있습니다.

SwiftUI는 UIKit과 다르게 모디파이어가 View를 반환하기 때문에 체이닝으로 레이블 다음에 바로 연결해서 사용한 것을 볼 수 있습니다.

뷰를 변수에 담고, 뷰 변수명을 여버런 쓸 필요 없는 점은 매우 편한 점이지요. 반면에 단점처럼 느껴지는 특징도 있습니다. 그건 “값”에 대해 설명하면서 말씀드리도록 하겠습니다.

값(Value) : accessibilityValue

값은 생소하실수도 있고, 아니실 수도 있습니다. 말그대로, 값으로서의 정보를 제공할 수도 있고, iOS에서 제공하지 않는 상태정보를 대신하여 상태정보를 제공할 수 있습니다.

UIKit
class ViewController: UIViewController {
//...생략

override func viewDidLoad() {
    let myBtn = UIButton()
    myBtn.text = "?"
    myBtn.accessibilityLabel = "Screen Reader Label"
    myBtn.accessibilityHint = "Double Tap to Open"
    myBtn.accessibilityValue = "Collapsed"
    view.addSubView(myBtn)            
}
}    

SwiftUI

struct MyHelpButton : View {
var body:some View {
    Button(label:{ Image(systemName:"questionmark.circle") })
    .accessibilityLabel("도움말").accessibilityHint("이중탭하여 도움말 펼치기")
    .accessibilityValue("축소됨")
}
}

마찬가지로, value도 UIKit은 setter, SwiftUI는 모디파이어 체이닝을 사용하는 모습입니다.

SwiftUI에서 낯설게 느껴지는 점은, 직접 객체 메소드나 객체 프로퍼티에 접근하지 않는다는 점입니다. 기본적인 SwiftUI의 컨셉을 이해하지 않으면, 불편하게 느껴질 수도 있습니다.

UIKit에서는 accessibilityValue가 get인 동시에 set입니다. 그 말은, 기존에 있던 값을 완전히 대체하지 않고, 재사용할 수 있습니다. 이렇게요.

myBtn.accessibilityValue = myBtn.accessibilityValue + ", 추가상태"

그런데, SwiftUI에서는 기본적으로 이런 코드가 없습니다. 그래서 SwiftUI에서는 @State 래퍼 변수를 사용하여 값을 담아두고 그 상태변수를 참조합니다. 이 래퍼 변수 안에 담긴 값이 바뀌면 렌더링됩니다.

“이런 방식, 어디서 많이 봤는데?”라고 느끼실 수도 있습니다. 선언형 프레임워크라면 거의 비슷한 방식을 사용합니다. 웹 개발자라면, 리액트를 떠올리실겁니다.

“accessibilityValue를 다시 사용하면 되는거 아니에요?”라는 호기심을 가지실 수 있으나, 모디파이어는 더하는 방식이 아닌 덮어쓰는 방식입니다. 만약, 이 @State 래퍼를 사용하지 않는다면, 트레이트가 아닌 여러개의 상태정보 중 하나를 제거해야 할 때, 매번 중복된 텍스트를 써줘야 됩니다.

state를 외부에서 제어할 수 있게 public 프로퍼티로 공개해놓는다면, 의도적으로 덮어쓰는 것을 피하고, state 프로퍼티값만 참조하여 관리할 수 있습니다.

연습 예제: 드랍다운메뉴 만들기

감이 안오실 수 있으니 예제를 하나 만들어볼게요. 드랍다운 메뉴를 한번 만들어 볼게요.

import SwiftUI
struct DropDownStateButton: View {
    var label:String
    var list:[String]
    @State private var expanded:Bool = false
    @State var selected:Int = 0
    @State var states:String = ""
    @AccessibilityFocusState var dropdownFocus:Bool

SwiftUI는 뷰 객체에 직접 접근하여 값을 가져올 수 없으므로 지속적으로 전달될 값들을 프로퍼티로 만들어줘야 합니다.

@State가 변경될 때마다, 해당 프로퍼티가 사용된 부분이 업데이트됩니다.

label과 list를 기본으로 받고, expanded로 메뉴가 열렸는지, selected로 현재 선택된 옵션이 몇번쨰인지, states는 실제 스크린리더. 사용자에게 전달할 커스텀 상태정보입니다. accessibilityValue에 쓰일겁니다.

아, 그리고, 이 아티클에서 다루지 않은 @AccessibilityFocusedState라는 래퍼도 보이는데, 당장은 신경쓰지 않으셔도 됩니다. 옵션을 눌러 선택했을 때, 메뉴가 닫히고나면, 버튼으로 초점을 돌려보내주는 역할을 하는 셔틀같은 친구입니다.

import SwiftUI
class DropDownViewModel : ObservableObject {
    @Published var value:String = ""
    @Published var expanded:String = ""
}

상태정보 텍스트를 정의할 ViewModel입니다.

struct DropDownButton: View {
    var label:String
    var list:[String]
    @State private var expanded:Bool = false
    @State var selected:Int = 0
    @StateObject. var vm = DropDownViewModel()
    @AccessibilityFocusState var dropdownFocus:Bool

View를 채택하는 DropDownButton를 만듭니다.

  1. label은 좌측에 표시될 눈에 보이는 제목입니다. 변할 일이 없으므로 일반 변수 속성으로 선언합니다.
  2. list는 선택가능한 항목 스트링 배열입니다. 변할 일이 없으므로 일반 변수 프로퍼티로 선언합니다.
  3. expanded는 목록이 확장되었는지 축소되었는지에 관한 부울값입니다.
  4. selected는 몇번째 항목이 선택되어 있는지를 지정하고 가리키는 정수입니다.
  5. vm은 상태객체로 아까 선언한 DropDownViewModel()입니다.
  6. dropdownFocus는 목록이 닫히면 접근성 초점이 맨 처음으로 돌아가지 않고, 버튼에 반환되도록 하는 속성입니다.

@StateObject는 접근성과 관련이 없고, @AccessibilityFocusState는 추후 다룰 예정이지만, 소개르 잠깐 하자면, VoiceOver초점을 특정요소로 보내줄 때 사용합니다.

    init(label:String, list: [String], selectedInit: Int = 0) {
        self.label = label
        self.list = list
        self.selected = selectedInit
    }

init을 선언하고, 기본값이 없는 label, list, selected의 기본값을 초기화할 수 있도록 합니다.

    var body: some View {
        HStack {
            Text("**\(label)**")
            Button (
                action: {
                    expanded = !expanded
                },
                label:{ HStack {
                    Text(list[selected])
                    Image(systemName:expanded ? "chevron.up" : "chevron.down")
                }}
            ).accessibilityFocused($dropdownFocus)
            .padding(5).foregroundColor(.black)
                .onAppear {
                    vm.expanded = "축소됨"
                    vm.value = list[selected]
                }.background {
                    RoundedRectangle(cornerRadius: 5).strokeBorder(lineWidth:1).foregroundColor(Color.accentColor)
                    .background {
                        RoundedRectangle(cornerRadius: 5).foregroundColor(.white)
                    }
                }

HStack으로 가로로 쌓이게 정렬합니다. 안에는 보이는 레이블인 Text, 텍스트 문자열은 아까 초기화한 label프로퍼티입니다. 그리고 버튼을 넣을겁니다. 코드가 매우 긴데, .background 모티파이어를 제외한 부분만 주목하시면 됩니다.

  1. action에는 closure가 들어갑니다. closure 안에서 누를때마다 expanded는 자기 자신을 항상 현재 부울과 반대값으로 토글합니다.
  2. dropdownFocus를 accessibilityFocused 모디파이어에 전달합니다. 이제, 제가 원하는 시점에 dropdownFocus를 true로 바꾸면, 해당 버튼으로 초점이 돌아오게 됩니다.
  3. onAppear : 버튼 action과 동일하게 클로져를 받습니다. 클로저 안에서는 뷰모델의 상태값을 초기화합니다. 제 드랍다운버튼은 무조건 false값으로 시작하므로, vm.expanded는 축소됨으로 초기화하고, vm.value는 list[selected]로 줍니다.

그 외에 나머지는 모두 디자인을 위한 모디파이어입니다.

                .accessibilityLabel(label)
                .accessibilityValue("\(vm.expanded), \(vm.value) 선택됨")
                .onChange(of: expanded) { v in vm.expanded = expanded ? "확장됨" : "축소됨"}
                .onChange(of: selected) { v in vm.value = list[selected] }

accessibilityLabel 모디파이어와 accessibilityValue 모디파이어를 설정합니다. 레이블에는 아까 초기화한 label프로퍼티 값이 들어가게 되고, value에는 vm.expanded와 vm.value값을 문자열 형태로 포매팅하여 넣습니다.

                .overlay(alignment:.topLeading ){
                if expanded {
                    GeometryReader { r in
                        VStack{
                            ForEach(Array(list.enumerated()),id: \.offset){index,item in
                                Button(item, action: {
                                    selected = index
                                    expanded = false
                                    dropdownFocus = true
                                }).padding(3)

.overlay 모디파이어로, 버튼에 특정 요소를 오버레이시킵니다. 버튼이 어디있든 버튼이 바로 아래에 목록이 나타나게 하기위해 .overlay를 사용한 것입니다.

  1. expanded가 true이면 VStack이 나타납니다.
  2. GeometryReader라는 특수한 객체를 씌웁니다. GeometryReader는 현재 Overlay가 걸린 버튼 크기를 알 수 있도록 해 줍니다.
  3. VStack을 넣습니다. 이 VStack이 버튼을 누르면 나타날 드랍다운목록입니다.
  4. View를 반복생성해주는 ForEach로 반복합니다. enumarated를 사용하고, id는 offset을 받도록했습니다.
  5. 클로저에는 배열의 인덱스와 아이템 자신을 인수로 둡니다. 버튼을 반복 생성하며, item 자신을 버튼 레이블로 사용합니다.
  6. 버튼 동작 클로저는 누르면 selected를 현재 버튼의 index로 설정하고, expanded를 false로 바꿔 해당 목록을 닫고, 그런 다음, dropdownFocus를 true로 설정하여 목록이 열리기 전 누른 버튼으로 VoiceOver 초점을 보냅니다.
                                .foregroundColor(selected == index ? .accentColor : .black)
                                .accessibilityRemoveTraits(selected != index ? [.isSelected] : [])
                                .accessibilityAddTraits(selected == index ? [.isSelected] : [])
                            }
                        }.padding(10).background(RoundedRectangle(cornerRadius: 5).foregroundColor(.white).shadow(color: .black,radius: 4)).offset(x:0,y:r.size.height)
                    }
                }
            }
        }
    }
}

foreground나 padding, background 등의 모디파이어는 모두 뒤로 재쳐두세요. 어처피 다 디자인 요소니까요. accessibilityAddTraits와 accessibilityRemoveTraits 모디파이어가 보일겁니다.

삼항연산자를 사용하여 selected값이 index와 일치하면, isSelected 트레이트를 추가하고, 일치하지 않으면 제거하도록 했습니다. 이제, VoiceOver는 목록내에서 어떤 버튼이 선택된 상태인지 알 수 있게 되었습니다.

“이게 뭐야”라고 하실지도 모르겠네요. 만들다보니 욕심을 내 버렸습니다(아하하하…) Hint를 제외한 모디파이어를 모두 사용한 결과물입니다.

앱을 빌드했을 때, 저 버튼은 VoiceOver가 “Choose a Fruit, 축소됨, Apple 선택됨, 버튼”처럼 읽게됩니다. 누르면 확장됨으로 정보가 바뀔것이며, 목록에서 Banana 항목을 선택하면, “Choose a Fruit, 축소됨, Banana 선택됨, 버튼”처럼 읽게될 것입니다.

결과물은 StateObject, State 래퍼가 붙은 프로퍼티들이 바뀔 때마다 바뀐 텍스트를 accessibilityValue에 넣을 것입니다.

Hint까지 사용한다면, expanded가 false일 때는 “두번 탭하여 확장하세요”, true일 때는 “두번 탭하여 축소하세요”같은 문구를 주면 되겠지요?

아렇듯, 텍스트와 관련된 접근성 구현은 기존 방식과 차이가 있을 뿐, 적용방법에는 큰 차이가 없습니다.

코드가 너무 지져분한데요?

네, 코드가 지져분해보일 수 있습니다. 저는 단순히 예제를 위해 한 덩이로 컴포넌트를 만들었지만, SwiftUI의 기본은 커스텀입니다. 뷰를 조합하고, 합성하는 것이 기본이지요.

코드의 가독성을 좋게 하려면, 커스텀 모디파이어를 만들어 사용하면 됩니다. 접근성도 마찬가지이지요.

iOS 커스텀 뷰의 꽃: accessibilityTraits

iOS는 HTML에서의 WAI-ARIA role처럼 요소 유형을 정는 접근성 트레이트가 있습니다. 그러나 role과 다른점은 접근성 트레이트는 역할이기도 하고, 상태정보이기도 합니다.

UIKit과 마찬가지로, SwiftUI에서도 이 용어와 개념은 동일합니다. 다만, 형식이 조금 다르거나, 트레이트와 별계로 따로 떨어져 나온 요소들도 존재하죠. 이번엔 그거에 관해 알아보도록 합시다.

트레이트를 제공하는 방법

SwiftUI에서는 UIKit과 달리, get/set 형태가 아니고, 모디파이어 메서드 형태로 사용합니다.

위에서도 잠깐 보셨을겁니다. 트레이트들을 추가하려면, .accessibilityAddTraits(_ traits:) 제거하려면 .accessibilityRemoveTraits(_ traits:)를 사용하게 됩니다.

기존 UIKit에서 했던 것 처럼, 여러개의 트레이트를 배열형태로 전달할 수 있습니다.

UIKit과 같은 트레이트

UIKit의 AccessibilityTraits와 SwiftUI에서의 AccessibilityTraits는 역할이 같지만, 쓰이는 값의 명칭에 조금 차이가 있습니다.

이름 패턴 변경

기존과 쓰임세는 같지만, 트레이트의 이름 패턴이 변경되었습니다.

“is~”처럼 앞에 be동사가 붙는 형태로 바뀐 경우입니다.

Button 유형을 예로 들어보죠. UIKit에서는 버튼을 주는 트레이트는 UIAccessibilityTraits.button 이었습니다.

SwiftUI에서는 AccessibilityTraits.isButton처럼 be동사 패턴으로 명칭이 바뀐 것입니다.

명칭이 바뀐 트레이트는 다음과 같습니다.

쓰임새 UIKit SwiftUI
버튼 .button .isButton
링크 .link .isLink
이미지 .image .isImage
머리말 .header .isHeader
키패드 .keyboardKey .isKeyboardKey
검색상자 .searchField .isSearchField
선택됨 .selected .isSelected
텍스트 .staticText .isStaticText
요약 .summaryElement .isSummaryElement

항목에서 제외되고 모디파이어가 된 트레이트 또는 속성

반면에, AccessibilityTraits 항목에서 제외되고 모디파이어 형태로 제공해야 하는 기존 트레이트도 있습니다.

.notEnabled(트레이트)
View.disabled(_ disabled:Bool) 모디파이어는 눈으로 보기에도 흐리게 표시되고, 스크린 리더도 활성화할 수 없음을 알립니다. 네이티브 효과를
.adjustable(트레이트)
View.accessibilityAdjustableAction(_:) 모디파이어에 조건으로 액션을 제공하도록 변경됨
.tabBar(트레이트)
tabBar는 SwiftUI에서 삭제되었습니다. TabBar를 대체하는 TabView 컴포넌트를 사용합니다.
.viewIsModal(프로퍼티)
viewIsModal 속성은 반대로 SwiftUI에서는 AccessibilityTraits.isModal로 추가되었습니다.

그외의 것들

다음 항목들은사라지지 않았고, 기존과 이름이 동일합니다.

  • .causesPageTurn: 페이지 전환
  • .updatesFrequently: 자주 바뀌는 정보의 업데이트를 감지
  • .playSound: VoiceOver 누름 효과음 제거
  • .startsMediaSession: 재생 버튼 등을 눌렀을 때, VoiceOver가 누른 항목을 다시 읽는 것을 방지

마치며

마치기 전에, SwiftUI는 완전히 다른 플랫폼이 아님을 상기해드리고자 합니다. SwiftUI는 객체지향식으로 UI를 구성하던 UIKit을 사용하시던 분들이라면 낯설게 느껴지실수도 있습니다. SwiftUI는 아직 UIKit에 있는 모든 메소드를 대체하지 못하고 있습니다. 그러나 나중에는 SwiftUI가 UIKit보다 주류가 될 것이라고 생각합니다.

SwiftUI에서 대체하지 못한 메소드도 있고, 기존에 쓰던 UI 컴포넌트를 완전히 버리지 못하는 상황도 있습니다. UIKit, SwiftUI는 서로를 보완하는 변환 객체를 가지고 있습니다. UIKit에서는 SwiftUI를, SwiftUI에서는 기존 UKit View를 사용할 수 있습니다. SwiftUI 사용을 망서리고 계시다면, 망서리지 마시고 사용해보셨으면 합니다.

이번 아티클은 여기서 마치며, 다음 2부에서 더 좋은 내용으로 찾아뵐 수 있도록 노력하겠습니다. 감사합니다.

 

참고사항:

위에서 만든 컴포넌트는 참고용도입니다. 드랍다운 레이어의 z값이 아래 표시됨을 유의하세요.

댓글 0
댓글을 작성하려면 해주세요.