Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

비밀번호 입력화면 개발 & SwiftUI용 앵프라맹스 구현 내용 #116

Merged
merged 6 commits into from
Nov 5, 2023

Conversation

minsangKang
Copy link
Member

@minsangKang minsangKang commented Nov 1, 2023

개요


변경사항

  • SignupSecureFieldView 생성
  • OverlayShowButtonForSecureFieldView 모디파이어 생성
  • SignupPasswordModel 생성
  • SignupPasswordView 생성
  • enum Stage & @FocusStatus를 활용하여 TextField 전환 및 순서 로직 개발
  • 기타 개선
    • KeyboardResponder singleton 형태로 개선
    • onReceive 모디파이어를 통한 Model의 Publisher 변화 -> 로직수행으로 개선
    • ScrollView 내 HStack을 통해 여백까지 스크롤 가능하도록 개선
아이폰 동작 아이패드 동작

Model

  • SwiftUI로 개발하면서 데이터 및 UI 상태들을 Model 내 지닐 수 있도록 개선하였다.
  • @StateObject를 사용하여 @Published 변수들을 지녔다.
  • Model 내 지닐 수 없는 @FocusState, @Environment 만 View 내 위치한 형태로 개발하였다.
  • 관련된 내용은 이전 PR를 참고: #115: 이메일 입력화면 Model 생성, Common View로 분리 및 개선

View 내 @focusstate 와 Model 내 @published 값 간의 데이터흐름 고민

@FocusState의 경우 @StateObject 내 지니기엔 많은 문제가 발생하였다.
따라서 고민 후 View 내 지닌채로 비즈니스 로직과 관련하여 Model 내 추가로 값을 지니며 이 둘간의 데이터 흐름에 대해 고민하였다.

@focusstate 값 변화 -> @published 값 반영

  • focus 값 자체의 변화의 경우 onChange 모디파이어를 통해 수신할 수 있었다.
  • onChange 모디파이어 내에서 model.updateFocus() 메소드를 통해 @Published 값을 반영하였다.
  • 다만, @FocusState 값만으로는 현재 진행중인 상태를 모두 설명할수는 없었기에 Model 내 추가적인 stage 값을 지닌 형태로 구현하였다.

@published 값 변화 -> @focusstate 값 반영

  • Model 내에서 비즈니스로직으로 인해 @Published 값이 변화된다.
  • View 내에서 .onReceive 모디파이어를 통해 publisher 값변화 수신할 수 있었다.
  • onReceive 모디파이어 내에서 @Published 값에 따라 분기처리하여 @FocusState 값을 반영하였다.

추가적인 에러상황

  • @FocusState의 경우 publisher를 사용할 수 없는 형태였다.
  • 따라서 오로지 값 자체가 변화되는 경우만 onChange 모디파이어를 통해 수신할 수 있었다.
  • 입력된 비밀번호가 정규식에 올바르지 않아 @FocusState 값을 동일하게 설정한 경우에도 onChange 모디파이어는 수신되지 않았다.
  • 따라서 Model 내 @Published 값 변화를 사용하여 onReceive 모디파이어 내에서 onChange 모디파이어 내 중복되는 로직이 필요할 수 밖에 없는 상황이 있었다.
  • @FocusState 값을 @StateObject 내에서 안전하게 @Published 형태로 사용할 수 있다면 해결될 것 같지만, 현재로선 해당방법이 최선으로 보여진다.

앵프라맹스 구현 내용

  • 비밀번호 입력 화면을 만들면서 여러가지 목표들이 있었다.
  • 특히 SecureField@FocusState를 사용하면서 몇가지 이슈가 생겼었고, 이러한 것들을 각 앵프라맹스 목표별로 나열하겠다.

화면에 진입시 자동으로 passwordTextField가 활성화되며 키보드가 표시된다.

  • onAppear 모디파이어 내에서 @FocusState 값을 설정하여 키보드를 표시하였다.
  • 다만 NavigationStack 에서 뒤로 이동하는 경우도 있기에 상황 분기처리를 넣었다.
.onAppear {
    if model.stage == .password {
        focus = .password
    }
}

SecureField에 입력시 첫글자는 원문으로 보여야 한다.

  • SecureField를 사용시 기본적으로 첫글자는 원문으로 보인다.
  • 하지만 .onChange 모디파이어를 사용하여 text내용에 따른 UI 분기처리가 존재하는 경우 첫글자가 원문으로 보이지 않는 현상이 발생하였다.
  • 따라서 .onChange 모디파이어를 제거하였고, text 내용에 따른 경고문 표시여부의 경우 model 내 computed property 를 통해 해결하였다.
SignupSecureFieldView(type: .password, keyboardType: .alphabet, text: $model.password, focus: $focus) {
    model.checkPassword()
}
SignupTextFieldUnderlineView(color: model.passwordTintColor)
// passwordTextField underline 컬러
var passwordTintColor: Color {
    if validPassword == false && password.isEmpty {
        return TiTiColor.wrongTextField.toColor
    } else {
        return focus == .password ? Color.blue : UIColor.placeholderText.toColor
    }
}

SecureField 우측에 눈 아이콘을 통해 원문을 볼 수 있어야 한다.

  • SecureField 에서 원문을 표시할 수 있는 기능이 없었다.
  • 따라서 상태에 따라 TextField로 표시되도록 구현하는것이 필요하였다.
  • ZStack 내 TextField, SecureField를 넣었고, .opacity를 통해 상황에 따라 표시되도록 구현하였다.
  • .opacity를 사용하는 방법이 @FocusState로인한 문제가 발생하지 않는 유일한 방법으로 보였다.
  • 원문을 표시할 지 선택하기 위한 버튼의 경우 .overlayShowButtonForSecureFieldView 모디파이어를 생성하여 적용하였다.
struct OverlayShowButtonForSecureFieldView: ViewModifier {
    var isVisible: Bool
    var isSecure: Bool
    var action: () -> Void
    
    func body(content: Content) -> some View {
        content
            .overlay {
                if isVisible {
                    HStack {
                        Spacer()
                        Button {
                            action()
                        } label: {
                            Image(systemName: isSecure ? "eye.slash" : "eye")
                        }
                        .foregroundColor(.secondary)
                    }
                }
                
            }
    }
}
struct SignupSecureFieldView: View {
    ...
    @Binding var text: String
    @FocusState.Binding var focus: SignupTextFieldView.type?
    let submitAction: () -> Void
    @State private var isSecure: Bool = true
    
    var body: some View {
        ZStack {
            TextField("", text: $text)
                ...
                .focused($focus, equals: self.type) // textField 활성화값 반영
                .overlayShowButtonForSecureFieldView(isVisible: isVisible, isSecure: isSecure, action: {
                    isSecure.toggle()
                })
                .opacity(isSecure ? 0 : 1)
            
            SecureField("", text: $text)
                ...
                .focused($focus, equals: self.type) // textField 활성화값 반영
                .overlayShowButtonForSecureFieldView(isVisible: isVisible, isSecure: isSecure, action: {
                    isSecure.toggle()
                })
                .opacity(isSecure ? 1 : 0)
        }
        .onChange(of: focus) { newValue in
            if newValue == nil {
                isSecure = true
            }
        }
    }
    ...
}

SecureTextField 에 원문이 표시된채로 입력할 수 있어야 한다.

  • 위 SignupSecureFieldView 코드에서 TextFieldSecureField 내 동일한 $text 값을 사용하였다.
  • 따라서 opacity로 인해 SecureField가 가려져도 입력된 값이 TextField로 표시가 되었다.

SecureTextField 입력이 완료되면 secure 상태로 표시되어야 한다.

  • 위 SignupSecureFieldView 코드에서 .onChange(of: focus) 모디파이어를 통해 비활성화 되었을 경우 isSecure 값을 true로 설정하여 원문이 표시되지 않도록 하였다.

키보드의 done 버튼을 눌러 다음 TextField로 이동한다.

  • SignupSecureTextField 내 .submitLabel 모디파이어와 .onSubmit 모디파이어를 적용하여 done 버튼이 표시되도록 하였다.
  • done 버튼을 누르면 클로저로 받은 것을 수행되도록 하였다.
struct SignupSecureFieldView: View {
    ...
    let submitAction: () -> Void
    
    var body: some View {
        ZStack {
            TextField("", text: $text)
                ...
                .submitLabel(.done) // 키보드 done 버튼 활성화
                .onSubmit { // 키보드 done 버튼 액션
                    submitAction()
                }
            
            SecureField("", text: $text)
                ...
                .submitLabel(.done) // 키보드 done 버튼 활성화
                .onSubmit { // 키보드 done 버튼 액션
                    submitAction()
                }
        }
        ...
    }
    ...
}

만약 정규식에 어긋나는 경우 TextField가 그대로 유지되며 text는 지워진채로 하단에 경고문이 표시되며 빨간색으로 표시되어야 한다.

  • 키보드의 done 버튼이 눌리면 클로저로 전달된 model.checkPassword() 메소드가 실행된다.
  • model 내에서 정규식 체크를 통해 @published validPassword 값을 설정한다.
  • model 내에서 validPassword 값이 false인 경우 resetPassword2() 메소드가 실행되어 text가 지워지며 stage 값을 설정한다.
  • model 내 resetPassword2() 메소드로 인해 stage 값이 변경되면 onReceive 모디파이어를 통해 수신받는다.
  • onReceive 모디파이어 내에서 validPassword가 false 인 경우 @FocusState 값을 .password로 설정하여 TextField가 유지되도록 한다.
  • 빨간색 표시와 경고문의 경우 model 내 validPassword 값과 password 값을 비교하여 빨간색으로 표시된다.
struct ContentView: View {
    @ObservedObject var model: SignupPasswordModel
    @FocusState private var focus: SignupTextFieldView.type?
    
    var body: some View {
        ...
        VStack(alignment: .leading, spacing: 0) {
            ...
            SignupSecureFieldView(type: .password, keyboardType: .alphabet, text: $model.password, focus: $focus) {
                model.checkPassword()
            }
            SignupTextFieldUnderlineView(color: model.passwordTintColor)
            SignupTextFieldWarning(warning: "영문, 숫자, 또는 10가지 특수문자 내에서 입력해 주세요", visible: model.validPassword == false && model.password.isEmpty)
        }
        ...
        .onReceive(model.$stage) { stage in // stage 변화 -> @FocusState 반영
            switch stage {
            case .password:
                focus = .password
            case .password2:
                focus = .password2
            }
            scroll(scrollViewProxy, to: focus)
        }
    }
}
class SignupPasswordModel: ObservableObject {
    enum Stage {
        case password
        case password2
    }
    ...
    @Published var validPassword: Bool?
    @Published var stage: Stage = .password
    @Published var password: String = ""
    
    // passwordTextField underline 컬러
    var passwordTintColor: Color {
        if validPassword == false && password.isEmpty {
            return TiTiColor.wrongTextField.toColor
        } else {
            return focus == .password ? Color.blue : UIColor.placeholderText.toColor
        }
    }
}

extension SignupPasswordModel {
    ...
    // password done 액션
    func checkPassword() {
        validPassword = PredicateChecker.isValidPassword(password)
        // stage 변화 -> @FocusState 반영
        if validPassword == true {
            resetPassword2()
        } else {
            resetPassword()
        }
    }
    
    private func resetPassword() {
        validPassword2 = nil
        password = ""
        stage = .password
    }
}

TextField의 값이 입력되면 빨간색에서 파란색으로 변경되어 표시되어야 한다.

  • 위 코드내에서 model 내 computed property 값인 passwordTintColor 값 내에서 색을 반환한다.
  • onChange를 통해 text의 값 변경을 수신하여 UI를 표시하는 경우 SecureField의 첫글자가 원문으로 표시되지 않는 문제가 발생하였다.
  • 따라서 computed property 내에서 password.isEmpty 를 확인하는 식으로 구현하였다.

비밀번호를 다시 변경하고자 TextField를 선택시 이후에 입력된 TextField는 초기화되며 사라져야 한다.

  • 비밀번호 재입력이 표시된 상태에서 비밀번호를 입력하는 SecureTextField를 선택시 @FocusState 값이 변경된다.
  • @FocusState 값의 변경을 onChange 모디파이어를 통해 수신한다.
  • onChange 모디파이어 내에서 model.updateFocus(to: focus) 메소드를 실행한다.
  • updateFocus 메소드 내에서 선택된 focus 값에 따라 stage 값을 변경하며, 이후에 입력된 데이터를 초기화한다.
  • stage 값 변화로 인해 비밀번호 재입력 표시가 사라진다.
struct ContentView: View {
    @ObservedObject var model: SignupPasswordModel
    @FocusState private var focus: SignupTextFieldView.type?
    
    var body: some View {
        ...
        VStack(alignment: .leading, spacing: 0) {
            ...
            SignupSecureFieldView(type: .password, keyboardType: .alphabet, text: $model.password, focus: $focus) {
                model.checkPassword()
            }

            if model.stage == .password2 {
                NextContentView(model: model, focus: $focus)
            }
        }
        ...
        .onChange(of: focus) { newValue in // @FocusState 변화 -> stage 반영
            model.updateFocus(to: newValue)
            scroll(scrollViewProxy, to: newValue)
        }
    }
}
class SignupPasswordModel: ObservableObject {
    enum Stage {
        case password
        case password2
    }
    ...
    @Published var focus: SignupTextFieldView.type?
    @Published var validPassword: Bool?
    @Published var validPassword2: Bool?
    @Published var stage: Stage = .password
    
    @Published var password: String = ""
    @Published var password2: String = ""
    ...
}

extension SignupPasswordModel {
    ...
    // @FocusState 값변화 -> stage 반영
    func updateFocus(to focus: SignupTextFieldView.type?) {
        self.focus = focus
        switch focus {
        case .password:
            resetPassword()
        case .password2:
            resetPassword2()
        default:
            return
        }
    }
    
    private func resetPassword() {
        validPassword2 = nil
        password = ""
        stage = .password
    }
    
    private func resetPassword2() {
        password2 = ""
        stage = .password2
    }
}

아이패드의 경우 TextField 활성화에 따라 스크롤되어 화면에 가려지지 않도록 한다.

  • ContentView 내 body를 ScrollViewReader, ScrollView 내 컨텐츠를 표시하는 식으로 구현하였다.
  • @FocusState 값에 따라 scrollViewReader.scrollTo 모디파이어를 통해 스크롤되도록 구현하였다.
  • 원하는 위치로 스크롤이 되도록 하기 위하여 id 값들을 부여하였다.
  • 또한 표시된 키보드의 높이만큼 하단 .padding을 추가하여 정상적으로 스크롤이 이동되어 가려지지 않도록 구현하였다.
  • 다만, 맥의 경우는 관련없어야 하므로 #if targetEnvironment(macCatalyst) 분기처리가 추가되었다.
extension View {
    func sscroll(_ scrollViewProxy: ScrollViewProxy, to: any Hashable) {
        #if targetEnvironment(macCatalyst)
        #else
        scrollViewProxy.scrollTo(to, anchor: .bottom)
        #endif
    }
}
struct ContentView: View {
    @EnvironmentObject var environment: LoginSignupEnvironment
    @ObservedObject var model: SignupPasswordModel
    @FocusState private var focus: SignupTextFieldView.type?
    
    var body: some View {
        ZStack {
            ScrollViewReader { scrollViewProxy in
                ScrollView {
                    HStack {
                        Spacer()
                        VStack(alignment: .leading, spacing: 0) {
                            ...
                            SignupTextFieldWarning(warning: "영문, 숫자, 또는 10가지 특수문자 내에서 입력해 주세요", visible: model.validPassword == false && model.password.isEmpty)
                                .id(SignupTextFieldView.type.password)
                            
                            if model.stage == .password2 {
                                NextContentView(model: model, focus: $focus)
                            }
                        }
                        ...
                        .onChange(of: focus) { newValue in // @FocusState 변화 -> stage 반영
                            model.updateFocus(to: newValue)
                            scroll(scrollViewProxy, to: newValue)
                        }
                        .onReceive(model.$stage) { status in // stage 변화 -> @FocusState 반영
                            switch status {
                            case .password:
                                focus = .password
                            case .password2:
                                focus = .password2
                            }
                            scroll(scrollViewProxy, to: focus)
                        }
                        .frame(width: model.contentWidth) // vstack end
                        Spacer()
                    }
                }
                .scrollIndicators(.hidden)
            }
        }
    }
}
struct NextContentView: View {
    @FocusState.Binding var focus: SignupTextFieldView.type?
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ...
            SignupTextFieldWarning(warning: "동일하지 않습니다. 다시 입력해 주세요", visible: model.validPassword2 == false && model.password2.isEmpty)
                .id(SignupTextFieldView.type.password2)
        }
    }
}

아이패드의 경우 우측 여백을 통해 스크롤이 될 수 있도록 한다.

  • 위 ContentView 내 body 코드에서 ScrollViewHStack을 표시하도록 하였다.
  • HStack 내 VStack으로 표시되는 컨텐츠 양옆에 Spacer()를 추가하였다.
  • 최종적으로 양옆에 Spacer()로 채워진 ScrollView 내 .frame(width: model.contentWidth)로 width가 설정된 컨텐츠가 중앙에 표시되도록 하였다.
  • 이를 통해 양옆의 여백으로도 스크롤이 되도록 구현하였다.

최종적으로, 사용자는 키보드 입력과 done 버튼만으로 모든것을 할 수 있어야 한다.

  • 위 내용들에 따라 done 버튼을 통해 model.checkPassword() 메소드가 실행된다.
  • 상황에 따라 stage 값이 변화되어 @FocusState까지 변화되도록 구현하였다.

또한 사용자가 선택한 TextField에 따라 초기화되어 표시되어야 한다.

  • TextField를 선택시 @FocusState 값이 변화된다.
  • 위 내용들에 따라 @FocusState 값 변화를 onChange 모디파이어를 통해 수신하여 model.updateFocus() 메소드가 실행된다.
  • 상황에 따라 stage 값이 변화되어 UI가 변화되어 표시되도록 구현하였다.

@StateObject를 활용하여 Model 내 UI상태값들을 지닌 상태로 개발

  • 위 내용들에 따라 @EnvironmentObject, @FocusState 만 제외한 나머지 모든 값들을 @Published 값으로 @StateObject 내 지니도록 구현하였다.

Reference

@minsangKang minsangKang added refactor 코드 리펙토링 feature 기능 추가/변경/삭제 labels Nov 1, 2023
@minsangKang minsangKang merged commit 83b215c into master Nov 5, 2023
@minsangKang minsangKang deleted the feature/#114 branch November 5, 2023 06:13
@minsangKang minsangKang self-assigned this Nov 19, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature 기능 추가/변경/삭제 refactor 코드 리펙토링
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant