아티클

SwiftUI와 접근성 3부 - TabBar 구현하기

엔비전스 접근성 2023-09-25 10:22:03

SwiftUI에는 기본 네이티브 하단 탭막대를 구현하는 TabView 구성요소가 있으며, TabView의 TabViewStyle 변경을 통해 기본 탭막대나 페이지뷰컨트롤이 있는 페이지를 만들 수 있습니다.

그런데, 탭막대가 하단에 크게 한 가지만 있을 수도 있지만, 두 개 이상이 있을 수도 있습니다. 이럴 때 탭 요소 유형을 제공해야 하는데, SwiftUI에는 UIKit처럼 UIAccessibilityTraits.tabbar에 해당하는 Traits가 존재하지 않습니다.

이번 아티클에서는 SwiftUI에서 UIView의 UIAccessibilityTraits.tabbar를 구현하는 대안에 관해 소개하고자 합니다.

UIHostingViewController와 UIViewRepresentable

UIKit, SwiftUI는 서로에게 호환되게끔 컴포넌트를 변환하는 객체를 제공합니다. UIHostingViewController는 UIKit에 SwiftUI 뷰를 사용할 수 있도록 래핑하는 요소이며, 반대로 UIViewRepresentable은 UIKit요소를 SwiftUI요소로 사용할 수 있게 합니다.

Hello World Example - UIViewRepresentable

//
//  HelloSwiftUI.swift
//  accessibility-practice
//
//  Created by NVISIONS Accessibility on 8/21/23.
//

import SwiftUI
struct UILabelViewRepresentable:UIViewRepresentable {
    typealias UIViewType = UILabel
    var text:String = "Hello SwiftUI! I'm an UILabel of UIkit."
    var font:UIFont
    var color:UIColor = .fontPrimary
    init(text: String, font: UIFont, color: UIColor) {
        self.text = text
        self.font = font
        self.color = color
    }
    func makeUIView(context: Context) -> UIViewType {
        let label = UILabel()
        label.numberOfLines = 0
        label.lineBreakMode = .byWordWrapping
        label.setContentHuggingPriority(.required, for: .horizontal)
        return label
    }
    func updateUIView(_ label:UIViewType, context: Context) {
        label.backgroundColor = .clear
        label.font = font
        label.textColor = color
        label.attributedText = NSAttributedString(string: text)
        label.setNeedsLayout()
    }
    
    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UILabel, context: Context) -> CGSize? {
        if let width = proposal.width {
            let maxSize = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)
            let expectedSize = uiView.sizeThatFits(maxSize)
            return CGSize(width: width, height: expectedSize.height)
        } else {return nil}
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    
    class Coordinator:NSObject {
        typealias Parent = UILabelViewRepresentable
        var parent:Parent
        init(_ parent:Parent) {
            self.parent = parent
        }
    }
}

struct UILabelView :View {
    var text:String = "Hello SwiftUI! I'm an UILabel of UIkit."
    var font:UIFont = UIFont.systemFont(ofSize: 24.0)
    var color:UIColor = .fontPrimary
    var body : some View {
        UILabelViewRepresentable(text:text,font:font,color:color).padding()
    }
}

struct HelloSwiftUI: View {
    var body: some View {
        VStack (spacing:5){
            UILabelView(text:"Hello SwiftUI! I'm an UILabel from UIkit.\nI'm bigger and thicker than below label.",font:UIFont.systemFont(ofSize: 30,weight: .black))
            UILabelView(text:"Hello SwiftUI! I'm an UILabel from UIkit.\nI'm smaller and thinner than above label.",font:UIFont.systemFont(ofSize: 26,weight: .bold),color: .blue)
        }
    }
}

#Preview {
    HelloSwiftUI()
}

UIHostingController Example

let HelloUIKitView = UIHostingController(rootView: ContentView)

이 두개를 왜 여기서 설명하나요?

아까 전에 분명히 ’SwiftUI에는 UIAccessibilityTraits.tabbar와 동일한 Traits 가 없다”라고 언급했습니다. 이걸 설명했을 때, 감이 오신 분들도 계실겁니다.

없으니 UIKit에서 가져다 쓰는 것이죠. 물론 Apple에서 API를 통해 SwiftUI에 정식으로 탭막대를 AccessibilityTratis로 추가해 주는 것이 이상적이겠지만, 당장 없으니, 이가 없으면 잇몸이라도 써야겠지요?

CustomTabBarTrait UIViewRepresentable 구조체 소개

그래서, 조금 웃길 수도 있지만, UIHostingView로 감싼 HStack을 다시 UIViewRepresentable을 쓰면 어떻게 될 지 실험해봤습니다. 결과는 의외로 이 기법을 통해 UIAccessibilityTraits.tabbar를 구현하는 데 성공했습니다. 코드는 아래와 같습니다.

struct CustomTabBarTrait<V:View>: UIViewRepresentable {
    typealias UIViewType = UIView
    var hostedView:UIHostingController<V>
    var label:String?
    init(_ hostedView: UIHostingController<V>, label:String? = "") {
        self.hostedView = hostedView
        self.label = label
    }

    func makeUIView(context: Context) -> UIViewType {
        let view = self.hostedView.view!
        self.hostedView.sizingOptions = .preferredContentSize
        view.translatesAutoresizingMaskIntoConstraints = false
        view.setContentHuggingPriority(.required, for: .vertical)
        view.backgroundColor = .clear
        view.accessibilityTraits = .tabBar
        view.accessibilityContainerType = .semanticGroup
        view.accessibilityLabel = "\("\(NSLocalizedString(label ?? "", comment: "")) ")\(UITabBar().accessibilityLabel ?? "")"
        return view
    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func updateUIView(_ uiView: UIViewType, context: Context) {
    }
}

struct TabBarTraitsModifier:ViewModifier {    
    var label:String?
    @ViewBuilder
    func body(content: Content) -> some View {
        CustomTabBarTrait( UIHostingController(rootView: content),label: label)
    }
}

extension HStack {
    func addTabBarTraits (label:String? = "")->some View {
        self.modifier(TabBarTraitsModifier(label: label))
    }
}

CustomTabBarTrait UIViewRepresentable은 SwiftUI.View를 감싼 UIHostingViewController를 받습니다. 그런 다음, UIHostingViewController UIView에 레이아웃 관련 프로퍼티를 설정하고, accessibilityTraits를 UIAccessibilityTraits.tabbar를 지정해서 다시 반환합니다.

원리는 간단합니다. UIAccessibilityTraits.tabbar가 걸린 요소의 하위 요소는 무조건 ’탭’유형이 붙기 때문에 UIHostingViewController에 tabbar Traits를 줘서 커스텀 탭 막대를 만듭니다. HStack에 들어올 각각의 콘텐츠가 탭이 될 것입니다. 그리고, SwiftUI에 사용될 것이기 때문에 UIViewRepresentable로 이것을 다시 SwiftUI의 View로 돌려놓는 것입니다.

보시다보면, 조금 특이한게 보이실 겁니다. accessibilityLabel 부분에 UITabBar().accessibilityLabel을 사용한 것인데요. UIAccessibilityTraits.tabbar로 탭막대를 구현하면, UITabBar 클래스로 만든 네이티브 탭막대처럼 “탭막대”(영문: “Tab bar”)라는 컨테이너 이름을 읽지 않습니다. Apple에서 특별히 UITabBar를 위해 만든 API가 따로 존재하는 것은 아닙니다. 단순히 accessibilityLabel에 이 탭 막대라는 컨테이너 이름이 들어있던 것이였습니다.

그런데, 이런 의문이 조금 드실겁니다. ’그냥 탭막대라고 고정으로 써 놓으면 되지 않아?’라고요. 그렇게 했을 때 작은 문제점이 있습니다. 이미 리소스가 가까운 곳에 있음에도, ’탭 막대’라는 문자열을 localizable.strings에 또 일일히 등록해야한다는 불편한 현실이 기다리고 있는 것입니다. 그래서 UITabBar를 렌더링하지 않고, 객체를 초기화한 다음, accessibilityLabel만을 가져다 쓰는 것입니다. 이렇게 하게 되면, localizable.strings에 ’탭 막대’를 번역해 줄 필요가 없는 것이지요.

사용방법

SwiftUI에서 짧은 코드로 사용할 수 있게 뷰 수정자로 만들고, HStack에 해당 뷰 수정자를 체이닝할 수 있게 extension으로 등록했습니다. 아래는 위 코드를 사용하여 만든 커스텀 탭막대 컴포넌트 예제입니다.

enum TabSelectionStyle {
    case customize
    case background
    case font
}

struct TabSelectionPattern:OptionSet {
    let rawValue: Int
    static let bold = TabSelectionPattern(rawValue: 1<<0)
    static let bar = TabSelectionPattern(rawValue: 1<<1)
}


struct TabItemInit<Selection:Hashable,LabelText:StringProtocol> {
    var id:Selection
    var label:LabelText
    var image:Image?
    @State var selectedColor:Color?
    @State var unselectedColor:Color?
    @State var selectedFontColor:Color?
    @State var unselectedFontColor:Color?
}

fileprivate struct TabItem<Selection:Hashable,LabelText:StringProtocol>:View {
    var id:Selection
    var label:LabelText
    var image:Image?
    @Binding var selection:Selection
    @State var selectedColor:Color
    @State var unselectedColor:Color
    @State var selectedFontColor:Color
    @State var unselectedFontColor:Color
    var selectionStyle:TabSelectionStyle
    var selectionPattern:TabSelectionPattern
    
    init(
        id: Selection,
        label: LabelText,
        image: Image? = nil,
        selection: Binding<Selection>,
        selectedColor: Color = .accentColor,
        unselectedColor: Color = Color(UIColor.systemBackground),
        selectedFontColor: Color = .accentColor,
        unselectedFontColor: Color = .primary,
        selectionStyle:TabSelectionStyle = .customize,
        selectionPattern:TabSelectionPattern = [.bar,.bold]
        ) {
        _selection = selection
        self.id = id
        self.image = image
        self.label = label
        self.selectionStyle = selectionStyle
        self.selectionPattern = selectionPattern
        _selectedColor = State(initialValue: selectedColor)
        _unselectedColor = State(initialValue: unselectedColor)
        _selectedFontColor = State(initialValue: selectedFontColor)
        _unselectedFontColor = State(initialValue: unselectedFontColor)
    }
    
    var body:some View {
        Button (action:{
            selection = id
        }) {
            ZStack {
                if selectionStyle == .customize || selectionStyle == .background {
                    Rectangle().fill(selection == id ? selectedColor : unselectedColor)
                }
                if (selectionStyle == .font) {
                    Rectangle().fill(unselectedColor)
                }
                VStack {
                    VStack {
                        if let image = image {
                            image.font(.system(size:30)).frame(maxHeight:.infinity).foregroundColor(selection == id ? selectedFontColor : unselectedFontColor)
                                .fontWeight(selectionPattern.contains(.bold) && selection == id ? Font.Weight.heavy : Font.Weight.light)
                        }
                        Text(label).font(.system(size:15)).foregroundColor(selection == id ? selectedFontColor : unselectedFontColor)
                            .fontWeight(selectionPattern.contains(.bold) && selection == id ? Font.Weight.black : Font.Weight.light)
                            .transaction { t in
                                t.animation = nil
                            }
                    }.padding(10)
                    if (selectionPattern.contains([.bar])) {
                        Rectangle().fill(selection == id ? selectedFontColor : unselectedFontColor.opacity(0)).frame(height:10).frame(maxWidth: .infinity)
                    }
                }
            }
        }.frame(minWidth:50,maxWidth: .infinity,maxHeight:.infinity).accessibilityElement(children: .combine)
        .buttonStyle(.plain).accessibilityAddTraits(selection == id ? [.isSelected] : [])
        .accessibilityRemoveTraits(selection != id ? [.isSelected] : []).id(id)
    }
}

struct TabBar<Selection:Hashable,TabLabel:StringProtocol>:View {
    let tabBarName:String?
    @State var selectedColor:Color
    @State var backgroundColor:Color
    @State var unselectedFontColor:Color
    @State var selectedFontColor:Color
    @State var unselectedColor:Color
    @Binding var selection:Selection
    var selectionStyle:TabSelectionStyle
    var selectionPattern:TabSelectionPattern
    @State var tabItems:[TabItemInit<Selection,TabLabel>]
    init (
        selection:Binding<Selection>,
        tabItems:[TabItemInit<Selection,TabLabel>],
        tabBarName:String? = nil,
        selectedColor:Color = .accentColor,
        unselectedColor:Color = Color(UIColor.systemBackground),
        selectedFontColor:Color = .white,
        unselectedFontColor:Color = .primary,
        backgroundColor:Color = .clear,
        selectionStyle:TabSelectionStyle = .customize,
        selectionPattern:TabSelectionPattern = [.bar,.bold]
    ) {
        if let tabBarName = tabBarName {
            self.tabBarName = "\(tabBarName) "
        } else {
            self.tabBarName = ""
        }
        self.selectionStyle = selectionStyle
        self.selectionPattern = selectionPattern
        _selection = selection
        _tabItems = State(initialValue: tabItems)
        _backgroundColor = State(initialValue: backgroundColor)
        _unselectedColor = State(initialValue: unselectedColor)
        _selectedColor = State(initialValue: selectedColor)
        _unselectedFontColor = State(initialValue: unselectedFontColor)
        _selectedFontColor = State(initialValue: selectedFontColor)
    }
    
    var body:some View {
        ZStack {
            backgroundColor
            HStack (spacing:0){
                ForEach(Array(tabItems.enumerated()),id:\.offset) { index, item in
                    TabItem (
                        id: item.id,
                        label: item.label,
                        image: item.image,
                        selection: $selection,
                        selectedColor: (item.selectedColor ?? selectedColor),
                        unselectedColor: (item.unselectedColor ?? unselectedColor),
                        selectedFontColor: (item.selectedFontColor ?? selectedFontColor),
                        unselectedFontColor: (item.unselectedFontColor ?? unselectedFontColor),
                        selectionStyle: selectionStyle
                    ).id(item.id)
                }
            }.addTabBarTraits(label: tabBarName).frame(maxWidth: .infinity)
        }.frame(height: 100)
    }
}

struct TabPage<PageID:Hashable,Content:View>:View {
    var content:()->Content
    var pageId:PageID
    @Binding var selection:PageID
    init(pageId: PageID, selection: Binding<PageID>, @ViewBuilder content: @escaping () -> Content) {
        self.content = content
        self.pageId = pageId
        _selection = selection
    }
    var body:some View {
        if selection == pageId {
            VStack {
                ScrollView (showsIndicators: false){
                    VStack ( alignment: .leading ){
                        content()
                    }.padding(10).tag(0).frame(maxHeight:.infinity)
                }
            }.accessibilityElement(children: .contain)
            .accessibilityLabel("탭 콘텐츠").tag(pageId).id(pageId)
        }
    }
}
struct TabPages<Selection:Hashable,Contents:View>:View {
    var pages:[TabPage<Selection,Contents>]
    init(pages: [TabPage<Selection,Contents>]) {
        self.pages = pages
    }
    var body: some View {
        ForEach(Array(pages.enumerated()),id: \.offset) { index, page in
            page
        }
    }
}ㄴ

그리고 위 컴포넌트는 이렇게 사용합니다.

struct TextCombiner: View {
    @State private var texts:[String]
    var text:Text = Text("")
    init(_ texts: [String]) {
        _texts = State(initialValue: texts)
        for (idx,txt) in Array(texts).enumerated() {
            if idx == 0 {
                self.text = Text(LocalizedStringKey(txt))
            } else {
                self.text = self.text + Text("\n") +  Text(LocalizedStringKey(txt))
            }
        }
    }
    
    var body: some View {
        text.padding(.bottom,10)
    }
}

enum TabID:Hashable {
    case i18n
    case a11y
}

struct ParagraphsView: View {
    @State private var selection:TabID? = .i18n
    var page1Sentences:[[String]] = [
        ["i18n은 국제화(Internationalization)의 줄임말이다. 다국어 환경에서 엡이나 페이지 등 서비스를 이용할 수 있도록 번역하고, 현지화하는 일련의 작업을 말한다.","왜 i18n이 왜 국제화의 약자냐 하면, 첫글자 i와 끝글자 n사이에 18개의 알파벳이 들어가 있다는 의미에서 i18n이라고 줄여 부르기로 했다."],
        ["솔직히 Internationalization이라는 단어를 다 쓰기에는 인간적으로 너무 길다.","특정 언어권 사람들은 \"뭐가 기나?\"고 말할 수 있겠으나, 한 단어를 한 눈에 보기 어려울 정도로 긴 것은 확실히 가독성이 높진 않다.","특히, i18n으로 줄여야 하는 상황은 코딩이다. 코딩을 할 때에는 최대한 이해하기 쉬우면서도 약속되어있는 짧은 단어나 글자가 가독성을 높인다."],
        ["i18n과 유사하거나 거의 같은 뜻으로 쓰는 단어가 두어개 정도 존재한다.","Localization이나 Globalization 등이 있겠다.","그 중 가장 대표적으로 쓰이는 단어가 i18n(Internationalization)이다. Apple에서는 Localization(현지화)라는 표현을 사용한다."]
    ].map { sArr in
        localize(sArr)
    }
    var page2Sentences:[[String]] = [
        ["**A11y**는 접근성(Accessibility)의 줄임말이다. Internationalization과 마찬가지로 첫 글자 A와 마지막 글자 Y 사이에 11자의 알파뱃이 있다는 의미로 축약한 것이다.","접근성은 현실과 가상, 아날로그와 디지털, 오프라인과 온라인에 광범위하게 쓰이는 다양성을 뛰는 개념이다."],
        ["물리적(오프라인) 접근성이라고 한다면 건물 시설을 예로 들 수 있다. 10층짜리 아파트가 있다고 생각해보자. 그 아파트 출입문을 열고 들어가려면, 세 개의 계단을 올라야하고, 안에는 엘레베이터가 없다. 10층에 산다고 가정해보자.","벌써부터 다리가 후들거리지 않는가? 게다가 체력이 약하거나, 다리를 쓰지 못하는 상황이라고 가정해본다면 최악일 것이다."],
        ["그렇기 떄문에 고층 아파트에는 엘레베이터를 설치하는 것이다. 하반신 마비나 절단 등의 이유로 다리를 쓸 수 없거나 없는 경우, 휠체어를 타게 되는데, 그 사람들은 엘레베이터가 없다면 자신의 집으로 누군가의 도움없이는 접근할 수 없을 것이다", "그리고, 가정 중에 출입문이 세 개의 계단을 올라야 있다고 했다. **경사로**가 없다면 어떨까? 아마 건물 내부는 커녕, 출입문에 조차 접근할 수 없을 것이다."],
        ["엘레베이터가 있어서 탔다고 가정해보자. 엘레베이터 제어부가 문 오른쪽 구석에만 있고, 너무 높이 있다면 어떨까? 누르지 못하고 좌절할 것이다.","물리적 접근성은 위와 같은 상황을 예로 들 수 있는 것이다.","이런쪽으로 공부를 많이 한 사람이라면 유니버설 디자인(Universal Design)이 생각났을 수도 있겠다. 접근성은 그 Universal Design이 속한 더 큰 분류라고 할 수 있다."],
        ["가전이나 컴퓨터 프로그램, 앱과 같은 디지털 접근성은 어떨까? 별반 다르지 않다.", "여기서, 개발 프로세스에서 잘 고려하지 않는 부분 중 가장 큰 문제를 비유적으로 표현해 보자면 된장처럼 보이는 무언가를 똥인지 된장인지 찍어먹어 볼 수 밖에 없다는 아주 슬픈 점이다.","항아리가 3개 있고, 각각 고추장독, 된장독, 요강이 있다."],
        ["일반적으로 모양이나 색이 같으면 위치를 기억해두거나, 다른 모양, 다른 색상 항아리를 사용할 것이다.","그런데 모양과 질감이 모두 같다면 어떨까? 시각장애인은 촉각을 통해 모양과 질감으로만 사물의 정보를 받아들일 것이므로 점자 라벨을 붙이지 않는다면 구분할 수 없다.","개발과정에서 '대체텍스트'의 존재를 모르거나, 별 것 아닌 것으로 치부되어 추가하지 않으면, 시각장애인이나 글을 읽을 수 없어서 스크린리더를 사용하는 학습장애인은 말 그대로 똥인지 된장인지 찍어먹어봐야 하는 것이다.","이런 것을 현대 사회에서 우리는 정보접근성이라고 부른다."],
    ].map {
        sArr in localize(sArr)
    }
    
    var body: some View {
        
        VStack (alignment: .leading ){
            TabBar (
                selection: $selection,
                tabItems: [
                    TabItemInit(id:.i18n,label:"i18n이란?",image: Image(systemName: "globe")),
                    TabItemInit(id:.a11y,label:"a11y란?",image: Image(systemName: "figure.mixed.cardio")),
                ],
                tabBarName: "문단 콘텐츠",
                selectedColor: Color("woman-pink").opacity(0.2),
                unselectedColor: Color("body"),
                selectedFontColor: Color("woman-pink"),
                unselectedFontColor: .primary,
                selectionStyle: .customize,
                selectionPattern: [.bar]
            )
            TabPages(pages: [
                TabPage(pageId: .i18n, selection: $selection, content: {
                    VStack ( alignment: .leading ){
                        VStack(alignment:.leading) {
                            ForEach(Array(page1Sentences.enumerated()),id:\.offset) { _,texts in
                                TextCombiner(texts)
                            }
                        }
                    }.padding(10)
                }),
                TabPage(pageId: .a11y, selection: $selection, content: {
                    VStack ( alignment: .leading ){
                        VStack(alignment:.leading) {
                            ForEach(Array(page2Sentences.enumerated()),id:\.offset) { _,texts in
                                TextCombiner(texts)
                            }
                        }
                    }.padding(10)
                })
            ])
        }.frame(maxHeight: .infinity)
    }
}

개발에 그리 능숙하지 않아 불필요한 코드가 많을 것이나, 예제이니 양해부탁드립니다. 국제화와 접근성에 관해 설명하는 화면이고, i18n이란? 탭과 a11y란? 탭으로 나눠져 있습니다. 위에 구현된 것에 따라, binding된 상태가 바뀔 때마다 표시되는 콘텐츠가 변경됩니다.

제 탭 컨트롤의 구성요소는 총 3가지로 구분됩니다. 탭을 조작하기 위한 탭 목록, TabBar, TabBar로 변경된 selection 바인딩을 TabPage로 전달할 페이지 목록 컨테이너인 TabPages, 마지막으로, TabPages의 각 페이지 항목인 TabPage가 있습니다.

이 셋은 공통된 selection 바인딩 상태를 가지며, TabBar에서 이 바인딩 상태가 제어됩니다. 바인딩 변수가 변경될 때마다 상태 변경을 감지하고 TabPages에서 TabPage로 상태를 전달하여, 알맞는 페이지를 표시합니다.

결론

색상이나 선택 디자인 등을 지정할 수 있는 많은 파라미터가 보이지만, 중요한 것은 CustomTabBarTrait이므로 그것에 집중하시면 되겠습니다. 이건 어디까지나 제가 구현한 탭막대이고, 제 취향이 반영되어있으니까요. 이렇게, UITabBar를 SwiftUI에서 비슷하게 구현할 수 있게 만들었습니다. 이제, SwiftUI에서도 우리 취향에 맞는 탭컨트롤을 SwiftUI에서도 만들 수 있게 되었습니다.

다만 주의할 점이 있습니다. UIKit에서도 동일하게 발생하는 버그로, 탭막대 상위에 accessibilityElement 컨테이너가 있어선 안 된다는 점입니다. accessibilityElement(children:.contain) 등을 탭막대 상위 컨테이너에 지정하게 되면 임위탐색으로 탭막대를 탐색할 수 있는 크리티컬한 버그가 생깁니다. 이것은 UIKit에서 accessibilityContainerType을 semanticGroup로 지정했을 때 똑같이 발생하는 문제입니다. 이 점을 주의하여 사용해야 합니다.

또한, UIKit의 AutoLayout과 SwiftUI의 레이아웃 시스템 차이로 인해 배치나 크기 조절에 애로사항이 있을 수 있음을 알고 계시면 좋을 것 같습니다. 지금까지 SwiftUI와 접근성 아티클이었습니다. 읽어주셔서 감사합니다.

 

 

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