아티클

SwiftUI와 접근성 6부 - 커스텀 슬라이더

엔비전스 접근성 2024-03-14 14:12:44

안녕하세요. 엔비전스입니다.

벌써 SwiftUI와 접근성 아티클도 6부나 되었네요. 지난 시간에는 초점 순서와 초점 감지에 관해 배웠습니다. SwiftUI는 참 여러므로 유연하고 섬세한 것 같습니다. 이번 시간에는 다시 Widget을 만드는 아티클로 돌아왔습니다.

이번 주제는 iOS의 조절가능(Adjustable), 다 익숙한 표현으로는 “슬라이더”를 커스텀으로 만드는 방법입니다. iOS에는 이미 훌륭한 슬라이더 컴포넌트가 있습니다. UIKit의 UISlider, SwiftUI의 Slider가 기본 UI 컴포넌트입니다.

SwiftUI의 기본 Slider는 훌륭한 컴포넌트이고, 누가 뭐라고 해도 슬라이더를 제공하는 방법 중에서 접근성, 사용성 측면으로 가장 안정적인 방법입니다. 그러나 기본 컴포넌트의 스타일 변경이 자유로운 SwiftUI에서도 iOS17 기준으로 sliderStyle은 아직 지정할 수 없습니다. 기술적인 측면에서 드래그를 감지하고 처리하는 부분이 들어가기 때문에 추가가 늦는 것으로 보입니다.

이번 시간에는 iOS 제어센터에 있는 슬라이더 모양으로 한번 슬라이더를 구현해 볼겁니다.

슬라이더 기본 구현

//
    //  CustomSlider.swift
    //  accessibility-practice
    //
    //  Created by NVISIONS 접근성 on 12/28/23.
    //
    
    import SwiftUI
    
    extension Double {
        func stringWithoutZeroFraction(dp:Int = 2)->String {
            return truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", self) : String(format:"%.\(dp)f",self)
        }
    }
    
    
    struct CustomSlider: View {
        enum Orientation { case vertical, horizontal }
        @Binding var value:Double // 값
        @State private var percentRate:Double = 0 //값 퍼센트
        @State private var labelIsHidden:Bool = false
        var label:String
        var min:Double = 0
        var max:Double = 100
        var orientation:Orientation = .horizontal
        var trackColor:Color?
        var sliderColor:Color?

우선, 커스텀 슬라이더를 위한 View를 생성합니다.

  1. value는 실제 값을 바인딩으로 전달받는 변수입니다.
  2. percentRate는 초기화 할 때는 적지 않는 필드입니다. 현재 값의 백분율 비율이 몇인지 나타내기 위한 용도로 사용할 변수입니다.
  3. labelIsHidden도 마찬가지로 초기화 할 떄는 적지 않는 필드입니다.
  4. orientation은 해당 슬라이더를 어떤 측으로 만들지 정하는 속성입니다. 구조체를 생성할 때 구조체 안에 작성한 enum이며, horizontal과 vertical이 있습니다.
  5. tackColor는 값이 채워지지 않은 트랙 부분 색상입니다. 옵셔널이며 정하지 않으면 Color.primary를 트랙 색상을 사용하게 됩니다.
  6. sliderColor는 값이 채워진 트랙 부분 색상입니다. 옵셔널 옵셔널이며, 정하지 않으면 Color.accent 색상을 사용하게 됩니다.

다음에는 View 프로토콜이므로 당연히 body를 만들어야겠지요?

    var body: some View {
            let layout = orientation == .horizontal ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())

layout은 orientation에 따라 바뀌는 Stack 레이아웃입니다. 가로 방향이면 HStack으로 레이블, 슬라이더 순으로 배치하고, 세로 방향이면 VStack으로 슬라이더, 레이블 순으로 배치합니다.

        layout {
                if orientation == .horizontal && !labelIsHidden {
                    Text("**\(label)**").dynamicTypeSize(.large)
                }

가로상태에서 나타날 레이블을 작성해줍니다. 슬라이더 왼쪽에 가로로 레이블이 표시됩니다. labelIsHidden이 true이면 나타나지 않습니다.

            GeometryReader { reader in // 1
                    ZStack (alignment:.bottomLeading){ // 2
                        RoundedRectangle(cornerRadius: 12).background {  // 3
                            trackColor ?? Color.primary
                        }
                        if orientation == .horizontal { // 4
                            Rectangle().frame(width:reader.size.width*percentRate)
                                .foregroundStyle(sliderColor ?? Color.accentColor)
                        } else {
                            Rectangle().frame(height:reader.size.height*percentRate)
                                .foregroundStyle(sliderColor ?? Color.accentColor)
                        }
                        
                    }.gesture( DragGesture(minimumDistance: 0).onChanged { act in // 5
                        if orientation == .horizontal {
                            let tr = act.location.x > reader.size.width ? reader.size.width : (
                                act.location.x < 0 ? 0 : act.location.x) // location.x가 width보다 크거나 0보다 작을 수 없도록함.
                            value = max * (tr/reader.size.width) // 손가락 x 위치와 슬라이더 넓이값을 나눠 백분율을 구한 다음 max값과 곱한다.
                        } else if (orientation == .vertical) {
                            let tr = act.location.y > reader.size.height ? reader.size.height : (
                                act.location.y < 0 ? 0 : act.location.y) // location.y가 height보다 크거나 0보다 작을 수 없도록함.
                            value = max * (1-( tr/reader.size.height )) // 1과 손가락 y 위치와 슬라이더 넓이값을 나눈 값을 뺀 다음 max값과 곱한다.
                        }
                    })
                }.clipShape(RoundedRectangle(cornerRadius: 12))
                    .frame(width:orientation == .horizontal ? 120 : 40, height:orientation == .horizontal ? 40 : 120)
                    /*  값이 체워졌을 때, 사각형이 삐져나오는걸 방지하기 위해 clipShape로 몸체와 똑같은 도형으로 잘라줍니다.
                    / 가로/세로 값에 따라 가로/세로 크기 반영*/
                    .onChange(of: value) { new in // 6
                        percentRate = new/max
                        let gen = UINotificationFeedbackGenerator()
                        if new == max || new == min {
                            gen.notificationOccurred(.success)
                        }
                    }.onAppear { // 7
                        percentRate = value/max
                    } 

이 부분이 중요한 부분입니다. 접근성을 적용하기 전에 기본 슬라이더 기능을 만들어야 합니다. 접근성 기능은 넣었는데, 오히려 보조기술을 쓰는 사람이 슬라이더를 조절할 수 없으면 안되니까요.

  1. 드래그 제스처의 움직임에 따른 위치와 크기를 계산하여 백분율을 구해야 하기 때문에 GeometryReader를 선언해줍니다. GeometryReader 클로저 내부에 슬라이더 모양을 만들겁니다.
  2. 두 도형을 겹치기 위해 ZStack을 배치합니다. 하단, 왼쪽 정렬(bottomLeading)으로 설정했습니다.
  3. 슬라이더의 몸체가 될 둥근사각형을 기다랗게 만듭니다. 이 요소 위로 값이 채워지는 Rectangle이 덮어질 겁니다. 색상은 위에서 설명했듯, 지정하지 않았다면 primary로 지정됩니다.
  4. 값에 따라 채워질 Rectangle을 만듭니다. 방향축에 따라 세로 슬라이더면 height, 가로 슬라이더면 width를 계산해야 하기 때문에 조건 분기가 들어갑니다. percentRate에 따라 채워진 값이 시각화됩니다.
  5. 손가락으로 끌어서 값을 조절할 수 있어야 하기 때문에 gesture 수정자에 DragGesture를 선언해줍니다. 마찬가지로, 방향측이 가로 또는 세로인지에 따라 계산이 달라지므로 조건 분기가 들어갑니다.
  6. onChange로 value 값이 변경되면 백분율를 업데이트하여 값이 시각적으로 변경되도록 함.
  7. onApear로 요소가 나타날 때 value 초기값의 시각적인 백분율 표시를 업데이트함.
            if orientation == .vertical && !labelIsHidden {
                    Text("**\(label)**").dynamicTypeSize(.large)
                }

orientation 방향을 세로 축으로 설정하면 슬라이더 아래에 레이블이 표시됩니다. 마찬가지로 labelIsHidden이 true이면 나타나지 않습니다.

접근성 기능 적용

자 이제, 기본 슬라이더가 완성됐습니다. 그런데, 이 상태로는 스크린 리더 사용자는 이 슬라이더를 사용할 수 없습니다. 접근성 기술을 하나도 적용하지 않았습니다. 아래에서 적용해보도록 하죠.

        }.accessibilityHint("\(orientation == .horizontal ? "가로 슬라이더" : "세로 슬라이더")") // 1
            .accessibilityAdjustableAction({ direction in // 2 *
                var tempcalc:Double
                if direction == .decrement {
                    tempcalc = value-(max*0.1)
                    value = tempcalc <= 0 ? 0 : tempcalc
                } else if direction == .increment {
                    tempcalc = value+(max*0.1)
                    value = tempcalc >= max ? max : tempcalc
                }
            }).accessibilityElement(children: .combine) // 3
            .accessibilityLabel(label) // 4
            .accessibilityValue("\(value.stringWithoutZeroFraction()), \((percentRate*100).stringWithoutZeroFraction())%") // 5
        }
        
        // hideLabel로 isLabelHidden 값을 바꿀 수 있도록 함. self를 반환하여 다른 수정자를 사용할 수 있도록 함.
        func hideLabel(b:Bool)->some View {
            labelIsHidden = b
            return self
        }
    }
  1. accessibilityHint를 제공합니다. 세로 슬라이더인지, 가로 슬라이더 인지 힌트를 제공합니다. 스크린 리더 사용자도 드래그로 충분히 조절할 수 있으므로 힌트를 제공하면 도움이 됩니다. 꼭 주지 않아도 됩니다. 제 개인적인 취향이니까요.
  2. accessibilityAdjustableAction을 적용합니다. SwiftUI는 accessibilityTraits에 adjustable이 없습니다. 대신에 이 수정자를 적용하면 자동으로 “조절 가능” 트레이트가 붙게 됩니다. 영어뜻 그대로, .decrement는 감소, .increment는 증가입니다. 전 max의 10퍼센트로 계산하여 증감하도록 했습니다.
  3. accessibilityElement(children:.combine)을 통해 레이블과 슬라이더 초점이 분리되지 않도록 합니다.
  4. accessibilityLabel을 적용하여 이 조절가능이 어떤 역할을 하는 지 알 수 있도록 합니다.
  5. accessibilityValue로 값과 백분율을 알립니다. stringWithoutZeroFraction은 소숫점에서 0을 빼기위한 Double에 추가한 extension입니다.

자, 기본적인 접근성 기능도 모두 넣었습니다. 이제, 손가락으로 끌어서 값을 조절할 수도 있고, 스크린 리더 사용자는 한 손가락 위 또는 아래 쓸기를 통해 값을 10퍼센트씩 증감할 수 있게 됐습니다.

조금 더 업그레이드해봅시다

아주 심플하고 적당히 편한 컴포넌트가 되었습니다. 그런데, 기본적으로 iOS의 슬라이더는 세밀한 조절을 지원하지 않습니다. 세밀하게 조절할 수 있는 방법은 뭐가 있을까요? 기본적으로는 길게 누르고 드래그하여 값을 조절하는 방법이 있을겁니다.

그런데, 정확이 77이라는 값으로 조절하고 싶을 때를 가정해보면 어떤가요? 아마 모바일에서 슬라이더를 조절해보신 분이라면 생각보다 내가 원하는 값을 드래그하여 맞추기 어렵다는 걸 느끼실 겁니다. 하물며 손가락 동작이 자유로운 사람이 조절하기에도 세밀한 컨트롤이 어려운데, 만약 내가 상지가 불편하다고 가정한다면 원하는 값으로 슬라이더를 조절하기란 거의 불가능에 가까우리라는 걸 알 수 있습니다.

VoiceOver 사용자도 길게 누르고 드래그하여 값을 조절할 수 있으나, 역시 조작감이 어렵습니다. 쉬운 동작으로 하게 되면 위 컴포넌트는 10% 단위로 밖에 값을 조절할 수 없죠.

위에 완성된 슬라이더에서 이 단점을 보완하여 세밀한 동작이 가능하도록 한 층 업그레이드해봅시다.

import SwiftUI
    
    extension Double {
        func stringWithoutZeroFraction(dp:Int = 2)->String {
            return truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", self) : String(format:"%.\(dp)f",self)
        }
    }
    
    
    struct CustomSlider: View {
        enum Orientation { case vertical, horizontal }
        @Binding var value:Double
        @State private var percentRate:Double = 0
        var label:String
        var min:Double = 0
        var max:Double = 100
        var orientation:Orientation = .horizontal
        var trackColor:Color?
        var sliderColor:Color?
        @State private var labelIsHidden:Bool = false
        @FocusState private var inputFocus:Bool // 세밀한 조절을 위한 텍스트필드에 초점을 보내는 용도로 사용되는 상태 변수입니다.
        @AccessibilityFocusState private var sliderFocus:Bool // 편집을 완료했을 때 초점을 슬라이더로 돌려보내는 상태 변수입니다.
        @State private var inputValue:Double = 0 // 텍스트필드의 값을 저장하는 상태 변수입니다.
        @State private var inputMode:Bool = false // 텍스트필드를 포함한 모달 창을 표시/숨기는 상태 변수
        
        var body: some View {
            let layout = orientation == .horizontal ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())
            layout {
                if orientation == .horizontal && !labelIsHidden {
                    Text("**\(label)**").dynamicTypeSize(.large)
                }
                GeometryReader { reader in
                    ZStack (alignment:.bottomLeading){
                        RoundedRectangle(cornerRadius: 12).background {
                            trackColor ?? Color.primary
                        }
                        if orientation == .horizontal {
                            Rectangle().frame(width:reader.size.width*percentRate)
                                .foregroundStyle(sliderColor ?? Color.accentColor)
                        } else {
                            Rectangle().frame(height:reader.size.height*percentRate)
                                .foregroundStyle(sliderColor ?? Color.accentColor)
                        }
                    }.gesture(TapGesture().onEnded {
                        openInput() // VoiceOver를 끈 상태에서 슬라이더를 단순 누르기 동작으로 실행 시 편집창이 포함된 모달이 실행됨
                    }).gesture( DragGesture(minimumDistance: 0).onChanged { act in
                        if orientation == .horizontal {
                            let tr = act.location.x > reader.size.width ? reader.size.width : (
                                act.location.x < 0 ? 0 : act.location.x)
                            value = max * (tr/reader.size.width)
                        } else if (orientation == .vertical) {
                            let tr = act.location.y > reader.size.height ? reader.size.height : (
                                act.location.y < 0 ? 0 : act.location.y)
                            value = max * (1-( tr/reader.size.height ))
                        }
                    })
                }.clipShape(RoundedRectangle(cornerRadius: 12))
                    .frame(width:orientation == .horizontal ? 120 : 40, height:orientation == .horizontal ? 40 : 120)
                    .onChange(of: value) { new in
                        percentRate = new/max
                        let gen = UINotificationFeedbackGenerator()
                        if new == max || new == min {
                            gen.notificationOccurred(.success)
                        }
                    }.onAppear { percentRate = value/max }
                if orientation == .vertical && !labelIsHidden {
                    Text("**\(label)**").dynamicTypeSize(.large)
                }
            }.accessibilityHint("\(orientation == .horizontal ? "가로 슬라이더" : "세로 슬라이더")")
            .accessibilityAdjustableAction({ direction in
                var tempcalc:Double
                if direction == .decrement {
                    tempcalc = value-(max*0.1)
                    value = tempcalc <= 0 ? 0 : tempcalc
                } else if direction == .increment {
                    tempcalc = value+(max*0.1)
                    value = tempcalc >= max ? max : tempcalc
                }
            }).accessibilityAction(.default, { // onTapGesture뫄 마찬가지로 VoiceOver로 두 번 탭하면 편집 모달이 나타나도록 함.
                openInput() // 편집 모달을 표시하는 메소드
            }).accessibilityAddTraits(.isButton) // 누를 수 있기 때문에 button 트레이트를 추가로 넣어줍시다.
            .accessibilityElement(children: .combine)
            .accessibilityLabel(label)
            .accessibilityValue("\(value.stringWithoutZeroFraction()), \((percentRate*100).stringWithoutZeroFraction())%")
            .accessibilityFocused($sliderFocus) // 슬라이더 초점 변수를 accessibilityFocused로 전달
            .fullScreenCover(isPresented: $inputMode, content: { // 편집 모달 생성, inputMode 상태값에 따라 모달을 표시함
                ZStack {
                    Color.black.opacity(0.4).blur(radius: 0,opaque: true)
                    VStack {
                        Spacer()
                    TextField("값 입력", value: $inputValue, format: .number).keyboardType(.decimalPad).textFieldStyle(MyTextFieldStyle(label:"슬라이더 값"))
                        .accessibilityHint(Text("최솟값:\(min.stringWithoutZeroFraction()), 최댓값:\(max.stringWithoutZeroFraction())"))
                        .focused($inputFocus) // 모달이 열리면 이 곳으로 초점 이동
                        .onChange(of: inputValue) { new in
                            if new < min { // 값 변경을 감지하여, 값이 최솟값보다 작거나, 최댓값보다 크게 주는 걸 막습니다.
                                inputValue = min
                            } else if new > max {
                                inputValue = max
                            }
                        } // 값을 조절할 수 있는 텍스트 필드를 만듭니다.
                        HStack {
                            Text("최솟값:\(min.stringWithoutZeroFraction())") 
                            Text("최댓값:\(max.stringWithoutZeroFraction())")
                        }
                        Spacer()
                        HStack {
                            Spacer()
                            Button("완료") { // 완로를 누르면 모달을 닫습니다. save값을 true로 주면 value 변수에 저장됩니다.
                                closeInput(save:true)
                            }
                            Button("취소") {
                                closeInput() // 취소를 누르면 저장하지 않고 닫습니다.
                            }
                        }.padding(5)
                    }.frame(
                        minWidth:orientation == .horizontal ? 200 : 250,
                        maxWidth:orientation == .horizontal ? 250 : 300,
                        minHeight:orientation == .horizontal ? 250 : 200,
                        maxHeight:orientation == .horizontal ? 300 : 250
                    ).padding(10).background {
                        RoundedRectangle(cornerRadius: 10).foregroundColor(Color("section"))
                    }.onAppear {
                        inputFocus = true
                    }
                }.ignoresSafeArea() // 화면 전체를 덮기 위해 safeArea를 무시합니다.
            })
        }
        func closeInput(save:Bool=false){ // 모달을 닫고 저장하는 함수
            if( save ){ // 저장후 닫기
                value = inputValue
            } else {
                inputValue = value
            }
            inputMode = false
            sliderFocus = true
        }
        func openInput() { // 모달을 여는 함수
            inputMode = true
            inputValue = value
        }
        
        func hideLabel(b:Bool)->some View {
            labelIsHidden = b
            return self
        }
    }

다소 길어보일 수 있으나 크게 달라진 점은 탭 동작으로 편집 모달이 나타나게 했다는 점 뿐입니다. 아무리 끌어서 값을 조절할 수 있다고 하더라도, 세밀함에는 한계가 있습니다. 그 점을 보완하고자 누르면 편집 모달이 나오고, 텍스트 필드 내에서 값을 수정할 수 있게 한 것입니다. 이는 커스텀 뿐만 아니라 일반 Slider에도 적용가능할 것으로 보여집니다.

물론, 터치 키보드 입력 자체가 불편한 사람도 있을 수 있으나, 세밀하게 값을 조절하기에는 직접 원하는 값을 직관적으로 텍스트필드에 입력하는 방법만한 게 없습니다. 또 다른 방안으로는 편집창 대신에, 1씩 증감하는 버튼, 5씩 증감하는 버튼 등을 두는 것도 방법일 겁니다.

이번 시간에는 슬라이더에 관해 다뤄봤습니다. 도움이 되셨나요? 조절가능(adjustable)은 주로 슬라이더나, 스핀 버튼같은 값을 증감하는 요소를 만드는 데 특화되어 있지만, 캐러셀 슬라이드를 이전 또는 다음으로 넘기는 용도로 사용되기도 합니다. 대표적으로, iPhone 사진 앱에서 사진 선택기를 사용할 때, 이 조절가능이 적용되어 있습니다. 생각보다 꽤 다양한 용도로 조절가능 동작을 사용할 수 있으니 한번 여러 곳에 사용해보세요.

언젠가 SwiftUI의 기본 Slider 컴포넌트도 다른 뷰들 처럼 이런 복잡한 방법을 쓰지 않고도 Style 수정자로 모양을 쉽게 바꿀 수 있었으면 좋겠습니다. 이번 아티클은 여기까지입니다. 읽어주셔서 감사합니다.

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