아티클

SwiftUI와 접근성 2부 : 초점관리와 커스텀 액션

엔비전스 접근성 2023-07-24 15:10:48

안녕하세요, 엔비전스입니다. SwiftUI와 접근성 2부로 찾아뵙게 되었습니다. 이번 주제는 접근성에 있어서 가장 중요한 아티클이 아닐까 싶습니다. 대체텍스트가 없어도 이것이 적용되지 않는다면 무용지물이고, 제대로 적용하지 않으면 불편하기 짝이 없는 그 친구! 바로 초점에 관해서입니다.

들어가기 전에

편의를 위해 Property는 속성, 모디파이어(Modifier)는 수정자로 언급합니다.

초점 관리자 되기 프로젝트 1단계: 초점 보내기

UIKit에서는 특정 요소로 초점을 보내주기 위해서 UIAccessibility.post(notification: .layoutChanged,argument:UIView)를 사용했었습니다. SwiftUI에서도 LayoutChanged처럼 VoiceOver 초점을 관리할 수 있을까요?

이 섹션이 왜 있을까요? 당연히 있으니까 만들었지요! React의 Ref처럼, SwiftUI 수정자 중에는 포커스와 관련된 뷰 수정자가 있습니다. SwiftUI에는 두 가지의 초점관리 수정자와 속성 래퍼가 있고, 두 뷰 수정자에는 똑같이 두 가지의 오버로딩이 존재합니다. 속성 래퍼값은 바인딩으로 전달합니다.

@FocusState/@AccessibilityFocusState 속성 래퍼와 .focused/.accessibilityFocused 뷰 수정자

@FocusState 속성 래퍼는 focused 뷰 수정자에 사용되는 전용 속성을 만드는 래퍼 키워드입니다. 이 두개는 항상 따라다닌다고 보면 됩니다. 이는 다음에 설명할 AccessibilityFocusState와 accessibilityFocused또한 동일합니다.

@FocusState는 Hashable한 자료형 또는 Bool 자료형을 사용해야 합니다. 부울을 제외한 기본 자료형은 Hashable을 채택하므로 모든 기본자료형을 사용할 수 있습니다. FocusState는 하드웨어 키보드로 접근 가능한 요소에 초점을 보낼 때 사용합니다.

대표적으로 TextField가 이에 해당됩니다. iOS나 iPadOS는 TextField에 FocusState로 초점을 보내면 TextField에 캐럿이 표시되며, 블루투스 키보드가 연결되지 않은 상태라면 자동으로 소프트웨어 키보드(화상 또는 온스크린 키보드)가 하단에 나타나게 됩니다.

사용법은 아래와 같습니다.

struct ListView : View {
  @State var list:[String]
  @FocusState var keyboardFocus:Int?
  var body: some View {
      VStack {
          Button("마지막 텍스트 필드에 입력하기", action: {
              keyboardFocus = list.count-1
          })
          ForEach(list.indices, id: \.self) { idx in
              HStack {
                  Text("Field No.\(idx+1)")
                  TextField("TextField \(idx+1)", text:$list[idx])
                  .focused($keyboardFocus, equals: idx)
              }
          }
          Button("첫번째 텍스트 필드에 입력하기", action: {
              keyboardFocus = 0
          })
      }
  }
}

 

두 버튼을 눌렀을 때, 시스템 초점(키보드 초점)이 첫 편집창과 두번쨰 편집창으로 이동하게끔, keyboardFocus 변수의 값을 0과 list.count-1로 지정합니다.

keyboardFocus는 State이기 때문에, 값이 변경되면 자동으로 SwiftUI에서 인식하여 초점을 해당하는 Int값을 equals로 받은 요소에 초점을 보내게 됩니다.

Bool값으로 FocusState를 지정하게 되면, .focused(binding:equals) 오버로딩 대신, .focused(_ condition)에 부울 바인딩값을 전달합니다.

또는 enum에 Hashable을 채택하여 enum값을 equals에 넘겨서 초점 상태를 조절할 수 있습니다.

//
//  IndivisualDataForm.swift
//  accessibility-practice
//
//  Created by NVisions on 2023/06/20.
//

import SwiftUI
import Foundation

// Hashable 프로토콜을 채택한 열거형을 만듭니다. 이것을 focusState에 사용할 것입니다.
enum IndivisualDataFocus:Hashable {
  case firstName,lastName, gender, birthDay
}

class IndivisualDataFormViewModel:ObservableObject {
  // 텍스트필드와 같은 폼 정보 값을 지정하는 뷰모델
  @Published var firstName:String = ""
  @Published var lastName:String = ""
  @Published var gender:Int = 0
  @Published var birthDay:Date = Date()
}

//개인정보 서식 뷰 예제
struct IndivisualDataFormView : View {
  @State private var showBirthDayDatePicker:Bool = false
  @FocusState private var formFocus:IndivisualDataFocus? // 아까 만든 Hashable 열거형을 사용
  @AccessibilityFocusState private var formVoFocus:IndivisualDataFocus? // 위와 같음
  @ObservedObject private var viewModel:IndivisualDataFormViewModel = IndivisualDataFormViewModel() // 뷰모델 생성
  private var dateFormatter:DateFormatter = DateFormatter() // 커스텀 버튼에 전달할 포매팅된 날짜 텍스트를 전달하기 위해 만듬.
  init() {
      dateFormatter.dateStyle = .medium // YYYY-MM-DD만 표시할것이므로 medium
      dateFormatter.timeStyle = .none // 시간은 표시하지 않을것이므로 none
  }
  
  var body: some View {
      Grid {
          GridRow {
              Text("이름").dynamicTypeSize(.large)
              MyTextField("이름", text:$viewModel.firstName, onCommit: {
                  //sendFocus는 FocusState와 AccessibilityFocusState를 한꺼번에 바꾸는 함수   
                  //맨 아래에서 코드 확인 가능
                  // lastName으로 equals가 설정된 텍스트필드로 보냄.
                  sendFocus(.lastName)
              }).dynamicTypeSize(.large)
                  .accessibilityFocused($formVoFocus, equals: .firstName)
              .focused($formFocus, equals: .firstName).accessibilityLabel("이름")
          
          }
          GridRow {
              Text("성").dynamicTypeSize(.large)
              MyTextField("성", text:$viewModel.lastName,onCommit: {
                  // birthday으로 equals가 설정된 버튼으로 보냄.
                  sendFocus(.birthDay)
              }).dynamicTypeSize(.large)
                  .focused($formFocus, equals: .lastName).accessibilityLabel("성")
                  .accessibilityFocused($formVoFocus, equals: .birthDay)
          }
          GridRow {
              Text("생년월일").dynamicTypeSize(.large)
              HStack {
                  Button("\(dateFormatter.string(from: viewModel.birthDay))") {
                      // showBirthDayDAtePicker 스테이트가 true가 되면 생일 날짜피커 대화상자가 열림
                      // false면 닫힘
                      showBirthDayDatePicker = true
                  }.accessibilityFocused($formVoFocus, equals: .birthDay)
                  .accessibilityLabel("생일 날짜 선택")
                  .accessibilityValue("\(dateFormatter.string(from: viewModel.birthDay))")
                  Spacer()
              }
          }
          GridRow {
              Text("성별")
              HStack {
                  // 남자는 0 여자는 1, 남자가 선택
                  Button(action: {viewModel.gender = 0},label: {Text("남").dynamicTypeSize(.large)})
                      .buttonStyle(MyButtonStyle(color: Color("man-blue"))).grayscale(viewModel.gender == 0 ? 0 : 1)
                      .accessibilityLabel("남자")
                      .accessibilityAddTraits(viewModel.gender == 0 ? .isSelected : []) // 뷰모델값에 따라 선택정보 제공
                      .accessibilityRemoveTraits(viewModel.gender != 0 ? .isSelected : [])
                  Button(action: {viewModel.gender = 1},label: {Text("여").dynamicTypeSize(.large)})
                      .buttonStyle(MyButtonStyle(color: Color("woman-pink"))).grayscale(viewModel.gender == 1 ? 0 : 1)
                      .accessibilityLabel("여자")
                      .accessibilityAddTraits(viewModel.gender == 1 ? .isSelected : [])
                      .accessibilityRemoveTraits(viewModel.gender != 1 ? .isSelected : [])
                  Spacer()
              }.accessibilityElement(children:.contain).accessibilityLabel("성별 선택 그룹") // 성별 그룹임을 안내함.
              //accessibilityElement는 추후에 다루도록 함.
          }
      }.fullScreenCover(isPresented: $showBirthDayDatePicker) {
          //foullScreenCover는 어떤 상황이든 top Layer에 화면 전체를 가리는 뷰를 만드는 수정자임
          ZStack {
              // 레이어 전체색상
              Color.black.opacity(0.4).blur(radius: 0,opaque: true)
              VStack {
                  HStack {
                      Spacer()
                      Button("닫기") {
                          // 누르면 레이어 닫힘
                          showBirthDayDatePicker = false
                          sendFocus(.birthDay)
                      }.buttonStyle(MyButtonStyle(color:Color("woman-pink")))
                  }
                  DatePicker("",selection: $viewModel.birthDay,displayedComponents: .date)
                      .datePickerStyle(.graphical)
                      .frame(alignment: .center)
              }.padding(10).background {
                  RoundedRectangle(cornerRadius: 10).foregroundColor(Color("section"))
              }.frame(alignment:.center)
              .background {
                  // ClearBackgroundView()는 fullScreenCover를 투명 레이어로 만들기 위해 사용하는 요소임
                  // 기본적으론 다크모드면 검정색, 라이트모드면 흰색임.
                  ClearBackgroundView()
              }
              //safeArea(홈막대, 시간영역 등)도 모두 덮어야하기 때매 ingoreSafeArea() 수정자 지정
          }.ignoresSafeArea().accessibilityAction(.escape, { // 두 손가락 문지르기로 나갈 수 있게끔 처리
              showBirthDayDatePicker = false
              sendFocus(.birthDay)
          })
      }
  }

  // sendFocus 소스
  func sendFocus (_ focusFlag:IndivisualDataFocus,moveVoiceOverFocus:Bool = true){
      formFocus = focusFlag
      if(moveVoiceOverFocus) {
          formVoFocus = focusFlag
      }
  }
}
ClearBackgroundView 소스는 아래에 첨부합니다.

struct ClearBackgroundView: UIViewRepresentable {
  func makeUIView(context: Context) -> UIView {
      return InnerView()
  }
  
  func updateUIView(_ uiView: UIView, context: Context) {
  }
  
  private class InnerView: UIView {
      override func didMoveToWindow() {
          super.didMoveToWindow()
          
          superview?.superview?.backgroundColor = .clear
      }
      
  }
}

여기서 accessibilityFocused와 focused는 Hashable을 채택한 열거형 Binding값을 받았습니다. 그리고 equals에는 각 열거형의 항목을 매칭해줬습니다. 모든 열거형 값을 다 매칭해줬다면, FocusState든, AccessibilityFocusState든, 값만 다시 할당해주면 초점을 매칭해둔 요소로 보낼 수 있습니다. 예를들어, formFocus = .firstName과 같이 준다면, .firstName에 매칭해 둔 “이름” 편집창에 초점이 이동하게 됩니다.

FocusState와 AccessibilityFocusState는 속성 래퍼와 자료형만 다를 뿐 사용법이 같으므로, sendFocus처럼 묶어서 보낼 수도 있겠죠.

초점 관리자 되기 프로젝트 2단계: 뷰 그룹화와 초점 합치기

UIKit처럼 SwiftUI에도 .accessibilityElement(_ children:) 뷰 수정자가 존재합니다. children에 들어가는 값은 열거형으로, 다음 값을 가집니다.

  1. combine: 영어 단어 그대로, 하나로 묶습니다. 텍스트는 뷰 레이블로, 버튼은 커스텀 액션으로 합쳐집니다.
  2. contain: 이 역시 영어 단어 그대로 accessibilityContainer를 만들때 사용합니다. 위 예제에서 성별 선택 그룹을 통해 보셨을 겁니다. VStack, HStack 등에 사용하면 그룹화됩니다. 그룹화된 콘텐츠에 접근 시 지정된 레이블이 있으면 해당 레이블을 읽습니다. 또한, 탐색 스타일이 “그룹으로 탐색”으로 되어있다면, 해당 그룹 내부는 “내부로 이동” 제스처를 통해 그룹으로 들어가지 않으면 탐색할 수 없습니다.
  3. ignore: 역시나 뜻 그대로, “무시”입니다. 하위에 있는 모든 자식 뷰를 없는 것으로 처리합니다. 특정 그룹에 있는 뷰를 숨기고, 다시 정의할 때 유용하겠지요?

다음 예제를 보시지요.

//
//  NameList.swift
//  accessibility-practice
//
//  Created by NVisions on 2023/06/15.
//

import SwiftUI

// 항목을 추가하거나 초기화할 때, 기존에 있던 항목인지, 지금 생성을 시작한 항목인지 나타내는 옵션값
enum NameItemFlag {
   case exist, new
}

//초점관리용 - 목록항목
enum VoiceOverNameItemFocusLandmark : Hashable {
    case label
    case edit
}
// 초점관리용 - 목록
enum VoiceOverNameListFocusLandmark : Hashable {
    case txt_isEmpty
    case btn_addNew
}

// 항목 초기화용 항목객체
struct NameItem:Identifiable,Equatable {
    public let id:UUID = UUID()
    public var label:String
    public var editMode:Bool
    public var flag:NameItemFlag
    init(label: String, flag:NameItemFlag = .exist, editMode: Bool = false) {
        self.label = label
        self.editMode = editMode
        self.flag = flag
    }
}

// 이름 목록뷰
struct NameList: View {
    var listName:String
    @State var list:[NameItem]
    @State private var lastDeleted:Int
    @AccessibilityFocusState private var VoiceOverItemFocus: Int?
    @AccessibilityFocusState private var VoiceOverFocusLandmark: VoiceOverNameListFocusLandmark?
    
    init(listName:String, list: [NameItem]) {
        self.listName = listName
        _list = State(initialValue: list)
        _lastDeleted = State(initialValue: -1)
    }
    
    var body: some View {
        VStack ( alignment: .leading,spacing: 1 ) {
            if list.count > 0 {
                // 항목개수가 0보다 크면 스크롤뷰 안에 목록을 표시합니다.
                ScrollViewReader { r in
                    // 새 항목이 만들어지면 스크롤을 해당 위치로 보내기 위해 ScrollViewReader를 사용합니다.
                    ScrollView {
                        ForEach(list) { item in
                            NameItemView(item: item, list: $list,lastDeleted:$lastDeleted)
                                .accessibilityFocused($VoiceOverItemFocus,equals: list.firstIndex(where: {$0.id == item.id}))
                                .onAppear {
                                    if item.flag == .new {
                                        r.scrollTo(item.id,anchor:.bottom) // 새 아이템이 만들어지면 스크롤을 새 항목으로 이동시킵니다.
                                    }
                                }
                        }.onDelete(perform: { indices in
                            removeItems(at: indices)
                        })
                    }
                    Spacer()
                }
            }
            HStack {
                if ( list.count == 0 ) {
                    Text("항목이 없습니다.").accessibilityFocused($VoiceOverFocusLandmark,equals:.txt_isEmpty)
                    .onAppear {
                        Task {
                            try await Task.sleep(for:.milliseconds(1000))
                            VoiceOverFocusLandmark = .txt_isEmpty
                        }
                        // 항목이 없으면 항목이 없다는 메시지를 텍스트로 표시하고, 보이스오버 초점을 해당 영역으로 보냅니다.
                    }
                }
                Spacer()
                Button(action: {
                    list.append(NameItem(label: "홍길동",flag:.new,editMode: true))
                    // 맴버 추가 버튼을 누르면 홍길동이 추가되며 list 상태가 업데이트됩니다.
                }, label: {
                    HStack {
                        Image(systemName: "person.badge.plus")
                        Text("맴버 추가")
                    }
                }).buttonStyle(MyButtonStyle()).accessibilityLabel("명단에 새 맴버 추가")
                .frame(alignment: .trailing)
                .accessibilityFocused($VoiceOverFocusLandmark, equals: .btn_addNew)
            }
        }.padding(20).background {
            RoundedRectangle(cornerRadius: 7).foregroundColor(Color("section"))
        }.accessibilityElement(children:.contain)
        .accessibilityLabel(listName)
        .onChange(of:lastDeleted,perform:{ v in
            Task {
                // 항목 삭제시 초점관리
                if list.count >= 1 {
                    try await Task  .sleep(for:.milliseconds(100))
                    if v < list.count {
                        VoiceOverItemFocus = v+1
                    }
                    if v > 0 {
                        VoiceOverItemFocus = v-1
                    }
                }
            }
        })
        // 목록 이름과 함께 VStack을 그룹화합니다.
    }
    
    private func removeItems(at indices: IndexSet) {
        indices.forEach({ idx in
            list.remove(at: idx)
        })
    }
}

struct NameItemView: View { // 이름 항목뷰
    @State var item:NameItem
    @State private var editTemp:String = "홍길동"
    @Binding var list:[NameItem]
    @Binding var lastDeleted:Int
    @AccessibilityFocusState var VoiceOverFocusLandmark:VoiceOverNameItemFocusLandmark?
    @FocusState var editFocus:Bool
    
    var body:some View {
        HStack {
            if !item.editMode {
                HStack {// 편집모드가 아니면 이블과 함께, 수정/삭제 버튼을 표시합니다.
                    Text(item.label).frame(alignment: .leading)
                        .accessibilityAction(.default, {})
                    /*
                     Text는 아무런 기능도 하지 않아서 초점을 합칠 때, 올바르게 버튼 액션에 커스텀 엑션에 추가되게 하려면 accessibilityAction(.default, {})를 줘야합니다.
                     그렇지 않으면, 이 코드를 기준으로는 "항목 수정하기" 버튼 기능이 기본동작으로 할당됩니다. accessibilityAction이 설덩되지 않은 텍스트 다음에 Button이 있으면 텍스트 다음에 있는 버튼 동작이 활성화 동작이 됩니다.
                    */
                    Spacer()
                    Button(action: {
                        item.editMode = true
                        editTemp=item.label
                    },label: {
                        Image(systemName: "pencil").accessibilityLabel("\(item.label) 항목 수정하기")
                    }).buttonStyle(MyButtonStyle())
                    Button(action: {
                        removeItem()
                    },label:{
                        Image(systemName: "eraser.fill").accessibilityLabel("\(item.label) 항목 지우기")
                    }).buttonStyle(MyButtonStyle())
                }.padding(10)
                .accessibilityElement(children:.combine)
                .accessibilityFocused($VoiceOverFocusLandmark, equals: .label)
                //초점을 하나로 합치고 접근성 초점을 .label로 랜드마크화합니다.
            } else {
                HStack {//편집모드
                    Text(item.flag == .new ? "**새 맴버 이름**" : "**바꿀 이름**")
                    MyTextField("이름 입력", text: $editTemp,onCommit: {
                        ApplyItem()
                    }).accessibilityFocused($VoiceOverFocusLandmark, equals: .edit)
                    .focused($editFocus)
                    .onAppear {
                        Task {
                            //텍스트필드가 나타나면 0.5초를 기다리고 해당 텍스트필드로 초점을 보냄.
                            try await Task.sleep(for:.milliseconds(500))
                            VoiceOverFocusLandmark = .edit
                            editFocus = true // 키보드 입력이 준비되도록 FocusState또한 true로 업데이트
                        }
                    }
                    Button (
                        action:{
                            switch(item.flag) {
                                case .exist:
                                    item.editMode = false
                                    // 기존항목이면 편집모드만 종료됨
                                case .new:
                                    removeItem()
                                    // 새 항목을 작성 중에 "취소"를 누르면 삭제처리됨.
                            }
                        },
                        label:{Text("취소")}
                    ).buttonStyle(MyButtonStyle())
                    .disabled(editTemp.count == 0)
                    .accessibilityHint(editTemp.count == 0 ? Text("새 이름을 입력해야 편집을 완료할 수 있어요!") : Text("새 이름 \(editTemp), 새 이름으로 바꾸려면 두번 탭하세요."))
                    Button (
                        action:{
                            ApplyItem()
                            // 누르면 항목이 저장됨. ApplyItem()은 아래에 메소드로 확인가능합니다.
                        },
                        label:{Text("확인")}
                    ).buttonStyle(MyButtonStyle())
                    .disabled(editTemp.count == 0) // 텍스트필드가 비어있으면 버튼을 누를 수 없도록 딤드처리함.
                    .accessibilityHint(editTemp.count == 0 ? Text("이름을 입력해야 편집을 완료할 수 있어요!") : Text("입력한 이름 \(editTemp), 이 이름으로 등록하려면 두번 탭하세요."))
                }.padding(10).accessibilityElement(children:.contain)
                /* HStack을 그룹화합니다. 다른 항목에 이동했다가 해당 그룹에 초점을 보내면 편집중인 항목임을 먼저 알립니다.
                 컨테이너로 설정하고 레이블을 "{기존 이름} 편집"으로 지정*/
            }
        }.background {
            RoundedRectangle(cornerRadius: 7).foregroundColor(Color("item"))
        }
    }
    
    private func ApplyItem(){ //항목 이름 저장 메소드
        item.editMode = false
        let sameName = list.filter({$0.label == editTemp})
        if sameName.count > 1 {
            let duplicatedItemIndex = sameName.firstIndex(where: {$0.id == item.id})
            if let index = duplicatedItemIndex {
                item.label = "\(editTemp)(\(index+1))"
            }
        } else {
            item.label = editTemp
        }
        editTemp = ""
        item.flag = .exist
        Task {
            try await Task.sleep(for:.milliseconds(500))
            VoiceOverFocusLandmark = .label
        }
    }
    
    // 아이템 삭제 메소드
    private func removeItem() {
        if let index = list.firstIndex(where: { $0.id == item.id } ) {
            list.remove(at: index)
            lastDeleted = index
        }

    }
}

네. 엄청 길죠? 괜찮습니다. 여적지 봐왔던 것들을 종합하여 응용한 것 뿐입니다. 여기서 우리는 접근성에서 가장 많이 쓰일 accessibilityElement의 두 가지 ChildrenBehavior를 사용했습니다.

.combine을 사용하면, 하위에 있는 요소를 SwiftUI 내부 기준에 따라 초점을 하나로 합치게 됩니다. 그 과정에서, 텍스트 다음에 버튼이 있는 경우, “텍스트이름, 버튼”으로 뷰가 합쳐지며, 두번째 항목이었던 버튼의 동작을 기본동작으로 갖게 됩니다.

만약 이를 원하지 않고, 기본 동작(활성화)에 아무 기능도 없고, 버튼 이름이 커스텀액션으로 나타나길 원한다면, 텍스트에 반드시 accessibilityAction(.default, {})를 정의해서 아무런 기능도 하지 않는 클로저를 넘겨줘야 합니다.

그렇게 하면, 커스텀 액션에는 자동으로 다음과 같이 커스텀 액션이 정의되게 됩니다.

  1. 활성화(기본설정)
  2. OO 항목 수정하기
  3. OO 항목 지우기

여기서 주의할 점은, Text 요소에 onTapGesture를 등록해도, 해당 수정자는 커스텀액션으로 등록되지 않는다는 것으로, 액션을 따로 등록해줘야만 합니다.

버튼이 합쳐지면, 각 버튼 레이블을 가져와서 커스텀액션을 생성합니다. 예외적으로 첫번째 항목이 텍스트이면, 두번째 항목 버튼은 제차 강조하지만, 활성화(기본동작)으로 적용됩니다. 누가 보더라도 버튼 여러개를 하나로 합치는 것이 훨씬 코드도 짧고 간결하며 커스텀액션을 구현하기 쉽습니다.

만약 텍스트로 일일히 버튼 트레이트와 함께 액션을 줘야 한다면 이런 모습이 될겁니다.

HStack {
        //...생략
        Text("커스텀 액션 1").onTapGesture { CustomButtonAction1() }.accessibilityAction {CustomButtonAction1()}
        Text("커스텀 액션 2").onTapGesture { CustomButtonAction2() }.accessibilityAction {CustomButtonAction2()}
}.accessibilityElement(children:.combine)

보기만해도 깁니다. 물론 사용자정의 수정자를 적용하거나 뷰를 새로 만들면 될 일이지만, 기왕이면 그냥 버튼을 사용하는 편이 더 빠릅니다.

 HStack {
   //...생략
   Button("커스텀 액션 1") {
     action1()
   }
   Button("커스텀 액션 2") {
     action2()
   }
}.accessibilityElement(children:.combine)

 

끝입니다. 훨씬 간결하죠?

그런데 버튼 스타일이 적용되잖아요?

SwiftUI는 대체로 뷰를 합성하는 식으로 새 컴포넌트를 만들게끔 설계되어있지만, 기본 스타일을 바꿀 수 있는 요소또한 존재합니다. ButtonStyle을 채택하는 새 스타일을 만들거나, 단순히 텍스트랑 같아 보이게 만들고싶다면, plain 스타일을 사용하면 됩니다.

이렇게요.

HStack {
   //...생략
   Button("커스텀 액션 1") {
     action1()
   }.buttonStyle(.plain)
   Button("커스텀 액션 2") {
     action2()
   }.buttonStyle(.plain)
 }.accessibilityElement(children:.combine)

그래봐야 단 한줄씩 추가되었습니다. 그래도 여전히 커스텀 버튼보다 짧습니다. 눈에 보이는 버튼을 합치고, 그것들을 커스텀액션으로 대체할 때, 텍스트에 탭 제스처를 주는 것보다 버튼을 사용하는것이 훨씬 효율적입니다.

텍스트, SFSymbol 이미지 합치기

번외로 텍스트나 이미지를 합치는 기법에 관해 간단히 집고 넘어갑니다. SwiftUI의 Text()는 View를 채택하면서 동시에 StringProtocol을 채택합니다. Text는 즉, String과 유사하게 처리가 가능합니다. '+' 연산자를 통해 서로 이을 수 있다는 의미이죠. Text("Hello ") + Text("World")처럼요. 이미지는 Image(systemName:)을 활용할 때 이미지를 텍스트로 감싸서 처리할 수 있습니다. 예를 들어서 아래와 같이 이미지를 텍스트로 감싸서 텍스트와 함께 초점을 합쳐서 제공할 수 있습니다.

Text(Image(systemName:"globe")) + Text("Hello, World!")

버튼 안에 버튼을 넣으면 어떨까요?

발상을 전환하여, 버튼 View에는 Button(action:label)이라는 오버로딩이 있습니다.

두 번째 인자인 label은 SwiftUI View를 받는 ViewBuilder입니다. 이 안에 또 버튼을 넣으면 어떻게 될까요? 재미있게도 버튼 안에 있는 버튼이 커스텀 액션으로 부여됩니다. 마치 HStack에 버튼을 여러 개 넣고, accessibilityElement(children:.combine)을 준 것 처럼요.

때에 따라서는 아래처럼 Button 안에 Button을 넣거나, 이후에 설명할 accessibilityAction이 지정된 요소를 넣어서 커스텀 액션을 지정할 수도 있을 것 같습니다.

Button(action:{
  showMusicDetails()
},label:{
  HStack {
    VStack {
      Button(action:{playAction()}, {
        Text(music.musicName)+Text(Image(systemName:"play.fill"))
      })
      HStack {
        Button(music.albumName){music.showAlbumDetails()}
        Button(music.artistName){music.showArtistDetails()}
      }
    }
    Button(action:{music.playAction()},label:{Image(systemName:"star.fill")}).accessibilityLabel("즐겨찾기에 추가")
  }
}).accessibilityLabel("\(music.isPlaying ? "일시정지" : "재생") \(music.musicName) - \(music.artistName) \(music.albumName) \(music.playTime)")


단, 위 사례는 단순히 버튼안에 버튼을 넣었을 때, 모든 레이블을 읽게되므로, 상위 버튼에도 accessibilitylabel을 주는 것이 좋습니다.

오로지 보조 기술만을 위한 커스텀액션을 만들려면요?

그런데, 눈에는 아이콘이나 모양만 보이고, 보는사람은 드래그하거나, 꼬집거나 하는 어려운 제스처를 사용하지만, 스크린리더 사용자를 위해 보이지 않는 대체 제스처를 줄 때는 위처럼 따로 주는것이 바람직하겠지요.

여러개의 액션을 한 뷰에 등록할 때는 아래와 같이 .accessibilityActions 수정자에 버튼이나, accessibilityAction 수정자로 동작이 주어진 요소를 넣으면 됩니다. 이렇게 accessibilityActions 안에 주게되면, 눈에는 보이지 않고, 오로지 보조기술만을 위한 추가 동작 메뉴가 완성되는 것이지요.

.accessibilityAction의 여러 오버로딩들

accessibilityAction을 유심히 보신 분이라면, accessibilityAction만 3가지나 된다는 것을 느꼈을 겁니다. 각각 오버로딩의 사용 용도를 설명해 드리도록 하겠습니다.

accessibilityAction(_ actionKind:handler:)
AccessibilityActionKind 열거타입과 반환값이 없는 실행 클로저를 받는 수정자 오버로딩입니다.

Apple이 미리 정의한 액션은 .accessibilityAction(_ actionKind:handler:)를 통해 재정의 또한 가능합니다. 우리에게 친숙한 재정의 가능한 커스텀액션(또는 보조기술 제스처)는 아래와 같습니다.

  1. .default: 이름 그대로 기본 동작입니다. VoiceOver로 누르면 실행되는 동작입니다.
  2. .escape: .accessibilityPerformEscape의 swiftUI 버전입니다. 두 손가락을 문질렀을 때 어떤 동작이 실행될 지 재정의할 수 있습니다.
  3. .macgicTap: 마찬가지로 .accessibilityPerformMagicTap의 swiftUI 버전입니다. 두손가락 두번 탭 동작을 재정의할 수 있습니다.
accessibilityAction(_:)
아무 값도 반환하지 않는 실행 클로저만을 받는 수정자 오버로딩입니다. 주로, accessibilityActions 내에 하위요소로 사용할 요소를 제정의할 때 유용할 것입니다. 다만, accessibilityActions안에 있는 것은 이미 눈에 보이지 않으므로 굳이 이것을 사용하여 긴 코드를 만들 이유는 없을 것으로 보입니다. 다른 쓰임새가 있다면 공유해주세요!
accessibilityAction(named:_:)
동작 이름으로 쓰일 문자열과 실행 클로저를 받는 수정자 오버로딩입니다. 없는 동작을 원하는 이름으로 만들때 사용합니다.
accessibilityAction(action:label)
실행 클로저와 Label 뷰를 받아 새로운 동작을 만드는 수정자 오버로딩입니다. 이 역시 원하는 이름으로 새 동작을 만드는 데 사용합니다.

 

시현 영상:

자, 사용법은 꼼꼼히 보셨다면 이해되실 겁니다. 그런데, 잘 모르시는 분은 "그래서, 초점을 관리하고, 합치면 무슨 이점이 생기는데?"라는 생각이 드실 것 같아 다음 영상을 준비해 보았습니다.

초점을 합치기 전[동영상]

우선, 초점을 하나로 합치지 않은 상태입니다. 본 테스트 앱에서는 4개의 항목밖에 없기 때매, 고작 12개의 초점만 이동하면 됩니다. 별 것 아닌 것 같지요?

쇼핑 앱에서 100개씩 표시되는 상품 목록을 볼 때는 어떨까요? 더보기 메뉴, 장바구니 담기, 찜하기, 상품 상세로 가, 총 4개의 초점이 한 항목마다 있습니다. 스크린리더 사용자는 이 100개의 항목을 일일히 손가락으로 하나하나 쓸어가며 탐색하게 되는데, 탐색만 400번을 해야 한다는 얘기가 되겠지요

초점을 합친 후[동영상]

초점을 합친 후입니다. 4개의 항목에 나눠진 3개의 초점이 하나로 뭉쳐진 것을 한 눈에 볼 수 있습니다. 훨씬 탐색이 편해졌지요? 이러한 초점 합치기 작업은 항목이 몇 개 안될 때는 큰 체감이 안될 수 있지만, 데이터가 많아지고 그려진 항목 개수가 많다면, 그 진가를 발휘하게 됩니다. 또는 너무 복잡한 컨트롤을 하나의 초점으로 합치고 단순화할 수 있지요. 대표적인 사례는 홈 화면의 앱 편집모드가 있겠습니다.

오늘은 초점을 보내고, 합치고, 합쳐서 없어진 기능을 제공하는 방법에 관해 소개해 드렸습니다. 이 아티클은 오로지 접근성과 관련된 내용만 다르므로, SwiftUI의 기초는 다소 빠져있습니다. 잘 이해가 가지 않는다면 SwiftUI의 기초 내용부터 공부한 후 아티클을 읽어보는 것을 추천합니다.

지금까지 SwiftUI와 접근성 2부였습니다. 끝까지 읽어주셔서 감사드리며, 더 좋은 내용을 담은 3부로 찾아뵙도록 노력하겠습니다.

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