티스토리 뷰

토이 프로젝트를 진행하다가 이메일로 인증번호를 받아 인증하는 뷰를 만들 필요가 생겼다.

프로젝트의 로그인과 회원가입 화면을 SwiftUI로 작성하였기 때문에, 인증번호 입력 칸을 SwiftUI로 만들어야 했다.

 

내가 원하는 결과물

글자 수에 맞게 TextField를 분리한다.

한 글자씩 입력하면 다음 TextField로 이동하고,
글자가 지워지면 이전 TextField로 이동할 수 있도록 하는 것이 목표이다.

우선 한 글자만 입력받을 CharacterField를 선언한다.


한 글자만 입력받을 CharacterField

전체 코드

struct CharacterField: View {
    @State var character: String = ""
    @FocusState var focused: Int?

    var index: Int
    var onChange: ((_ index: Int, _ char: String) -> Void)

    var body: some View {
        TextField(text: $character) {
            Text("")
        }
        .lineLimit(1)
        .multilineTextAlignment(.center)
        .keyboardType(.numberPad)
        .background(
            RoundedRectangle(cornerRadius: 8)
                .foregroundColor(Color(red: 0.937, green: 0.945, blue: 0.965))
                .frame(height: 48)
        )
        .frame(maxWidth: .infinity, alignment: .center)
        .focused($focused, equals: index)
        .onChange(of: character) { newValue in
            onChange(index, character)

            if newValue.count > 1 {
                character = String(newValue[newValue.startIndex..<newValue.index(newValue.startIndex, offsetBy: 1)])
            }
        }

    }
}

변수 선언

struct CharacterField: View {
    @State var character: String = ""
    @FocusState var focused: Int?

    var index: Int
    var onChange: ((_ index: Int, _ char: String) -> Void)

    ...
}

입력한 글자가 저장될 수 있도록 character 변수를 선언하고, 원하는 TextField에 포커스가 갈 수 있도록 @FocusStatefocused 변수를 선언해준다.

CharacterField를 글자 수만큼 생성해서 쓸 것이기 때문에,

index 변수를 선언해서 몇 번째 CharacterField인지 식별한다.

그리고 글자가 입력되거나 지워지면 다음 Field로 넘겨주기 위해 onChange를 선언해준다.

body

var body: some View {
    TextField(text: $character) {
        Text("") // PlaceHolder
    }
    .lineLimit(1)
    .multilineTextAlignment(.center)
    .keyboardType(.numberPad) // 숫자만 입력할 때, 다른 글자도 입력하도록 허용하려면 적절히 수정해준다.
    .background(
        RoundedRectangle(cornerRadius: 8)
            .foregroundColor(Color(red: 0.937, green: 0945, blue: 0.965))
            .frame(height: 48)
    )
    .frame(maxWidth: .infinity, alignment: .center)
    .focused($focused, equals: index)
    .onChange(of: character) { newValue in
        onChange(index, character)

        if newValue.count > 1 {
            character = String(newValue[newValue.startIndex..<newValue.index(newValue.startIndex, offsetBy: 1)])
        }
    }
}

TextField에 RoundedRectangle 배경을 씌워주고, TextField에 대한 속성을 만져준다.

대충 이런 모양새...

.focused($focused, equals: index)

처음 선언했던 focused 변수로 이 TextField가 포커스를 받을지 여부를 결정한다.

focusedInt?로 선언되어 있는데, 이 변수의 값이 index와 같을 때 포커스를 준다.

    .onChange(of: character) { newValue in
        if newValue.count > 1 {
            character = String(newValue[newValue.startIndex..<newValue.index(newValue.startIndex, offsetBy: 1)])
        }

        onChange(index, character)
    }

TextField의 값 변화를 인식해 글자가 입력되거나 지워지면 onChange를 통해 알린다.

이 때, 여러 길이의 Text가 입력되어도 한 글자만 입력될 수 있도록 길이가 1보다 큰 String에 대해 앞글자만 substring하여 저장해준다.


여기까지 한 글자만 입력받는 `CharacterField`는 끝났다.

이제 이를 사용하여 여러 글자를 입력받을 수 있는 View를 만들어준다.


CharacterField를 여러개 묶은 SeparatedTextField

전체 코드

struct SeparatedTextField: View {
    var length: Int // 입력받은 글자 수
    @FocusState var focused: Int?
    // index별로 글자를 저장할 dictionary
    @State var characters: [Int : String] = [:]

    @Binding var string: String

    var body: some View {
        HStack {
            ForEach(0..<length) { i in
                CharacterField(focused: _focused, index: i) { i, c in
                    focused = c.isEmpty ? i - 1 : i + 1
                    characters[i] = c
                    string = getString()
                }
            }
        }.padding([.vertical], 16)
    }

    func getString() -> String {
        var str = ""
        for i in 0..<length {
            if let c = characters[i] {
                str += c
            }
        }
        return str
    }
}

변수 선언

struct SeparatedTextField: View {
    var length: Int
    @FocusState var focused: Int?
    @State var characters: [Int : String] = [:]

    @Binding var string: String

    ...
}

마찬가지로 이번엔 SeparatedTextField를 선언해준다.

SeparatedTextField는 입력 받을 글자 수 만큼 CharacterField를 생성하여 표시한다. 글자 수는 length에 따라 결정된다.

TextField의 포커스를 이동해주기 위한 focused 변수와, 입력된 글자를 저장할 characters 변수를 선언해준다.

마지막으로, characters 변수를 조합하여 하나의 String으로 묶어줄 string 변수까지 선언한다.

    ...

    func getString() -> String {
        var str = ""
        for i in 0..<length {
            if let c = characters[i] {
                str += c
            }
        }
        return str
    }

getString() 함수를 선언하여 dictionary에 [index : character] 형태로 저장된 글자를 String으로 변환해주도록 한다.

    var body: some View {
        HStack {
            ForEach(0..<length) { i in
                CharacterField(focused: _focused, index: i) { i, c in
                    focused = c.isEmpty ? i - 1 : i + 1
                    characters[i] = c
                    string = getString()
                }
            }
        }.padding([.vertical], 16)
    }

마지막으로 body에 length 수 만큼 CharacterField를 생성해준다.

여담으로 Xcode에서는 ForEach(0..<length)에서 length를 숫자 리터럴로 작성하라고 워닝을 띄우는데, 어짜피 length 변수는 초기에 필드를 생성할 때만 사용하고 중간에 값을 변경하진 않으니 상관 없을 것 같다.

...

CharacterField(focused: _focused, index: i) { i, c in
    focused = c.isEmpty ? i - 1 : i + 1
    characters[i] = c
    string = getString()
}

...

CharacterField를 생성할 때 onChange 클로저로 해당 CharacterField의 index와 입력된 글자 char를 넘겨주도록 선언했었는데,

여기서 글자가 입력되거나 지워졌을 때 다음이나 이전 CharacterField로 포커스를 이동시켜준다.

focused = c.isEmpty ? i - 1 : i + 1

위에서 @FocusState로 선언했던 focused 값에 글자가 입력됐으면 다음, 글자가 지워졌으면 이전 index로 이동하도록 다음 인덱스 값을 넣어준다.

그리고 나서 dictionary에 index 값을 key로 글자를 넣어주고, 이를 아까 만든 getString() 함수로 String으로 변환하여 string 변수에 저장하면 끝이다.

테스트

테스트 코드

struct CView: View {
    @State var text: String = ""

    var body: some View {
        VStack {
            Text(text)
            SeparatedTextField(length: 6, string: $text)
        }.padding(20)
    }
}

struct CView_Previews: PreviewProvider {
    static var previews: some View {
        CView()
    }
}

전체 모양새

 

8자리 & 6자리일 때 모습

이대로도 괜찮다고 생각하지만... 칸이 너무 넓다고 느껴지면 width를 조정하든 Spacer를 추가로 넣든 해서 조정하면 될 것 같다.

실제로 타이핑해보자

생각했던 것 보단 괜찮게 동작하는 듯 싶다.


아쉬운 부분

보통은 인증코드를 입력할 때 인증코드를 복사해서 붙여넣는 식으로 사용하는데, 내가 만든 뷰에서는 붙여넣으면 앞부분만 남기고 나머지는 지워진다. 자동으로 채워지지 않는다는 것...

인증코드는 회원가입을 할 때 한 번만 요청하는 것이 일반적이기 때문에 사용중 크게 불편함은 없을 것으로 생각하지만, 이런 사소한 부분에서 사용자가 느끼는 불편함은 생각보다 클 것이라 생각한다.

이런 부분은 앞서 만들었던 onChange 부분에서 입력된 텍스트의 길이가 SeparatedTextFieldlength와 같다면 자동으로 채워지도록 만들 수 있을 것 같다.

'iOS > SwiftUI' 카테고리의 다른 글

SwiftUI - MapKit 마커 추가하기  (0) 2023.02.19
SwiftUI - MapKit 사용하기  (0) 2023.02.13
SwiftUI - Multi Component PickerView  (0) 2023.02.10
댓글