LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


LINE Pay에서 SwiftUI TextField 사용하기

들어가며

안녕하세요. Pay App Dev1 팀에서 iOS 개발을 하고 있는 이혜진입니다. 최근 LINE Pay에서는 결제 화면에 UI 컴포넌트 단위로 SwiftUI를 반영하고 있습니다. 이번 글에서는 SwiftUI TextField를 LINE Pay에 적용한 경험을 공유하고자 합니다.

SwiftUI

WWDC19에서 발표된 SwiftUI는 iOS에서 UI를 구성할 수 있는 새로운 프레임워크입니다. 선언적 구문을 사용하며 이는 다음과 같은 장점이 있습니다.

  • 구현하고자 하는 UI 기능을 코드에 명시하기만 하면 됩니다.
  • 코드가 간결해집니다.
  • 코드 가독성이 향상됩니다.
  • 더 빠르게 구현할 수 있습니다.
  • 결과적으로 유지 관리가 용이해집니다.

또한 UIKit과 호환되기에 SwiftUI에서 제공하지 않는 인터페이스 요소를 사용하려는 경우 함께 사용할 수 있습니다.

SwiftUI로 전환하는 배경

먼저 한 클래스에 상태에 따른 분기가 너무 많아 코드를 읽기 어려웠습니다. LINE Pay 결제 화면의 경우 한 클래스에서 모델 관리와, API 호출, 그에 따른 UI 노출 상태 변경 로직이 집약돼 있었습니다. 조건이 너무 다양해 로직을 쉽게 파악하기 어려웠고 어느 시점에 UI가 변경되는지 알기 어려웠습니다.

두 번째로 스토리보드를 사용해 UI를 구현했기 때문에 UI 구현 사항을 파악하기 어려웠습니다. 스토리보드는 개발할 때는 UI를 한 눈에 볼 수 있다는 장점이 있지만 코드 형태로는 파악하기 매우 어렵다는 단점이 있습니다. 또한 PR 리뷰가 어렵습니다. 이 때문에 스토리보드에서 UI 문제가 발생할 경우 원인을 찾기 어려웠습니다.

세 번째로 사용자의 상태에 따라 빈번하게 변하는 결제 화면에는 반응형 메커니즘을 제공하는 SwiftUI가 유리했습니다. 결제 화면은 사용자의 상태에 따라 제공되는 UI가 변경될 가능성이 높습니다. 예를 들어 포인트의 경우에도 사용자가 포인트만 보유하고 있을 때와 포인트와 LINK를 둘 다 보유할 때는 아래와 같이 서로 다른 형태로 노출됩니다.

  • 포인트만 보유한 경우
  • 포인트와 LINK를 보유한 경우

결과적으로 스토리보드와 복잡한 코드 때문에 리팩토링이 필요한 상황에서, 빈번하게 변경될 결제 화면은 데이터가 변경될 때마다 UI를 새로 만들어 노출할 수 있는 SwiftUI가 적합하다고 판단했습니다.

TextField를 사용할 영역

아래 이미지는 LINE Pay 결제 화면에서 포인트 영역입니다.

먼저 TextField를 사용할 영역을 이해하기 위해 LINE Pay 포인트에 대해 간단하게 말씀드리겠습니다. LINE Pay에서는 사용자에게 '포인트'를 제공합니다. 포인트는 부가적인 결제 수단으로, 사용자는 포인트를 사용해 결제 금액의 일부 혹은 전체를 지불할 수 있습니다. 사용자는 사용할 포인트를 직접 입력할 수 있는데 이때 사용자가 사용할 포인트를 입력할 수 있는 UI를 제공하기 위해 TextField를 사용합니다.

여기서 TextField가 제공해야 하는 기능은 다음과 같습니다.

  • 입력된 숫자의 1000 단위마다 금액 구분자(,)를 추가해야 합니다.
  • 입력된 숫자의 끝에는 '포인트'와 같은 단위가 표시돼야 합니다.
  • 단위는 사용자가 수정할 수 없는 영역이어야 합니다.
  • 포인트 입력이 끝나면 키보드 툴바에 위치한 버튼을 클릭해 키보드를 숨길 수 있어야 합니다.

SwiftUI TextField 사용하기

SwiftUI에서는 TextField를 이용해 값과 바인딩된 텍스트 필드(text field)를 만들 수 있습니다.

값이 String이면 텍스트 필드는 사용자가 입력하거나 편집할 때 이 값을 계속 업데이트합니다.

@State private var username: String = "" // 업데이트되는 값.
@FocusState private var emailFieldIsFocused: Bool = false

var body: some View {
    TextField(
        "User name (email address)",
        text: $username
    )
}

값이 String이 아니면 사용자가 편집을 커밋할 때(키보드의 리턴(return) 키 클릭) 값을 업데이트합니다.

@State private var number: Int = 0

let formatter: NumberFormatter = {
    let formatter = NumberFormatter()
    formatter.numberStyle = .decimal
    return formatter
}()

var body: some View {
    VStack(spacing: 20) {
        TextField("title", value: $number, formatter: formatter)
        Text("number : \(number)")
    }
}

iOS 15 이상에서 TextField 사용법

onSubmit

onSubmit에 사용자가 리턴 키를 클릭할 때 실행할 로직을 구현합니다.

submitLabel

뷰의 submitLabel을 설정합니다. TextField에서는 리턴 키가 submitLabel입니다.

@State private var username: String = ""

var body: some View {
    TextField(
        "User name",
        text: $username
    )
    .submitLabel(.done)
    .onSubmit {
        validate(name: username)
    }
}

FocusState

FocusState는 뷰가 포커스되면 바인딩 값이 true가 되며, 값을 true로 설정하면 포커스가 뷰로 이동합니다.

@State private var username: String = ""
@FocusState private var usernameIsFocused: Bool

var body: some View {
    TextField(
        "User name",
        text: $username
    )
    .focused($usernameIsFocused)
}

Bool값뿐 아니라 아래와 같이 equals를 이용할 수도 있습니다.

enum Field {
    case username
    case email
}

@State private var username: String = ""
@State private var email: String = ""
@FocusState private var focusedField: Field?

var body: some View {
    Form {
        TextField("User name", text: $username)
            .focused($focusedField, equals: .username) // equals 사용.
        TextField("email", text: $email)
            .focused($focusedField, equals: .email)

        Button("Done") {
            if username.isEmpty {
                focusedField = .username
            } else if email.isEmpty {
                focusedField = .email
            } else {
                validate(name: username)
            }
        }
    }
}

Toolbar

TextField에 포커스될 때 나타나는 키보드에 툴바를 제공할 수 있습니다.

@State private var username: String = ""
@FocusState private var usernameIsFocused: Bool

var body: some View {
    TextField(
        "User name",
        text: $username
    )
    .focused($usernameIsFocused)
    .toolbar { // 툴바 설정
        ToolbarItemGroup(placement: .keyboard) {
            Spacer()
            Button("Done") {
                usernameIsFocused = false
            }
        }
    }
}

사실 LINE iOS의 배포 타깃은 iOS 14.0입니다

앞서 'iOS 15 이상에서 TextField 사용법'이라는 제목에서도 짐작할 수 있다시피 이 기능들은 iOS 15부터 제공되는 기능들입니다. 그런데 LINE iOS는 배포 타깃(deployment target)이 iOS 14.0이었기 때문에 LINE Pay에서는 이런 인터페이스를 사용할 수 없었습니다.

또한 '사용자가 입력한 포인트 금액 뒤에 위치한 포인트 단위는 사용자가 수정할 수 없다'는 사항을 구현하는 것도 문제였습니다. 사용자가 포인트 단위로 커서를 옮기려고 할 때마다 이를 허용하지 않고 단위 앞으로 커서를 옮기는 기능을 구현해야 했지만 SwiftUI TextField만으로는 어려웠습니다.

iOS 14에서 TextField 사용법

iOS 14에서 앞서 설명한 기능을 제공하기 위해 SwiftUI가 UIKit과 호환되는 특성을 이용하기로 결정했습니다. UIKit을 SwiftUI에서 사용하기 위해서는 UIViewRepresentable이라는 프로토콜을 이용해야 합니다.

UIViewRepresentable

SwiftUI에서 UIKit 뷰를 사용하기 위해 필요한 wrapper입니다. 생성과 업데이트가 SwiftUI 뷰와 유사하기에 현재 상태 정보로 뷰를 구성할 수 있습니다. 뷰에서 발생하는 변경 사항은 SwiftUI로 구성한 뷰들에게 자동으로 전달되지 않습니다. 따라서 상호 작용하기 위한 Coordinator를 제공해야 합니다. Coordinator에서는 target actiondelegate를 구현할 수 있습니다. UIViewRepresentable로 만든 wrappermakeUIViewupdateUIView를 통해 UIKit을 사용할 수 있습니다.

makeUIView

제공된 정보로 구성된 UIKit 뷰입니다. 뷰를 만들고 초기 상태를 구성하며 뷰를 처음 만들 때 한 번만 호출됩니다.

func makeUIView(context: Context) -> UITextField {
    let textField = UITextField()
    textField.placeholder = placeholder
    textField.returnKeyType = returnKeyType
    textField.delegate = context.coordinator

    // 툴바를 제공합니다.
    let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 44))
    let flexSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
    let doneButton = UIBarButtonItem(
        barButtonSystemItem: .done,
        target: self,
        action: #selector(textField.didTapDoneButton)
    )
    toolbar.items = [flexSpace, doneButton]
    textField.inputAccessoryView = toolbar

    return textField
} 

updateUIView

updateUIViewmakeUIView 호출 이후 모든 업데이트가 발생할 때 호출됩니다. 앱 상태가 변경되면 SwiftUI는 해당 변경 사항에 영향을 받는 인터페이스 부분을 업데이트합니다. SwiftUI는 해당 UIKit 뷰에 영향을 미치는 모든 변경 사항에 대응하기 위해 이 메서드를 호출하며, 이 메서드로 context로 제공된 새 상태 정보와 일치하도록 뷰 구성을 업데이트합니다.

func updateUIView(_ uiView: UITextField, context: Context) {
    // text + 단위로 텍스트 필드에 입력합니다.
    uiView.text = "\(text)\(suffix)"

    // 텍스트 필드의 포커스 위치를 변경합니다.
    context.coordinator.setCursorPosition(uiView)

    // isEditing값에 따라 텍스트 필드의 포커스 상태를 변경합니다. 
    if isEditing, !uiView.isFirstResponder {
        uiView.becomeFirstResponder()
    } else if !isEditing, uiView.isFirstResponder {
        uiView.resignFirstResponder()
    }
}

makeCoordinator

makeCoordinator는 커스텀 Coordinator를 제공합니다. 아래는 Coordinator에서 UITextFieldDelegate를 구현한 예시 코드입니다.

CustomUITextField
func makeCoordinator() -> Coordinator {
    Coordinator(suffix: suffix, isEditing: $isEditing, text: $text)
}
Coordinator
class Coordinator: NSObject, UITextFieldDelegate {
    @Binding private var isEditing: Bool
    @Binding private var text: String

    private let suffix: String
        
    init(
        suffix: String,
        isEditing: Binding<Bool>,
        text: Binding<String>
    ) {
        self.suffix = suffix
        self._isEditing = isEditing
        self._text = text
    }

    // 텍스트가 입력될 때마다 단위를 함께 노출합니다.
    func textField(
        _ textField: UITextField,
        shouldChangeCharactersIn range: NSRange,
        replacementString string: String
    ) -> Bool {
        ...
    }

    // isEditing값을 변경합니다.
    func textFieldDidBeginEditing(_ textField: UITextField) {
        if !isEditing {
            isEditing = true
        }
    }

    // isEditing값을 변경합니다.
    func textFieldDidEndEditing(_ textField: UITextField) {
        if isEditing {
            isEditing = false
        }
    }

    // 사용자가 텍스트 필드의 텍스트를 선택할 때 커서 위치를 변경합니다.
    func textFieldDidChangeSelection(_ textField: UITextField) {
        setCursorPosition(textField)
    }

    // 커서를 단위 앞으로 이동합니다.
    func setCursorPosition(_ textField: UITextField) {
        ...
    }
}

결과

SwiftUI로 만든 TextField는 다음과 같은 선언적 코드로 사용할 수 있습니다. 내부적으로 UITextFieldDelegate를 구현함으로써 LINE Pay에 필요한 기능이었던 툴바와 커서 이동 기능을 해결할 수 있었습니다.

SwiftUI
@State private var username: String = ""
@State private var isFocused: Bool = false

var body: some View {
    Form {
        CustomUITextField(
            "User name",
            text: $username,
            isEditing: $isFocused,
            returnKeyType: .done
        )
        Button("change focus state") {
            isFocused = !isFocused
        }
    }
}

마치며

SwiftUI로 뷰를 구현하면 확실히 간단하고 보기 쉬운 코드를 작성할 수 있지만, 아직은 현재 개발하는 환경에서 가능하지 않은 기능을 파악한 뒤 다른 방법을 찾아내는 데에 무시할 수 없는 시간이 소요된다고 느꼈습니다. 따라서 앞서 말씀드렸던 SwiftUI의 5가지 장점 중 4가지는 살릴 수 있었으나 기능을 처음 개발할 때에는 '구현에 필요한 시간이 절약됩니다'라는 장점을 살리기 부족했다고 생각합니다. 그런데 만약 이 글이 SwiftUI TextField를 사용하는 개발자분들께 '시간을 절약하는 도움'을 드린다면, 5가지 장점을 모두 살린 SwiftUI 개발이 될 것이라고 기대하며 글 마치겠습니다. 긴 글 읽어주셔서 감사합니다.