[TIL]04.20

rbw·2022년 4월 20일
0

TIL

목록 보기
5/97
post-thumbnail

SwiftUI - State & Data Flow

state와 데이터의 흐름에 대해 알아보는 파트

MVC: The Mammoth View Controller

UIKit과 AppKit을 사용 했다면, MVC(Model-View-Controller)의 개념은 익숙할 것이다. 이는 Massive View Controller로 불리기도 한다.

MVC에서 View는 사용자 인터페이스, Model은 데이터, Controller는 Model과 View를 동기적으로 연결하는 역할을 하고, 이는 자동적으로 이루어지지 않기 때문에 Model에 변화가 일어날 때 가능한 경우마다 View를 업데이트 하는 코드를 작성해야 한다.

하지만 임의의 데이터 구조가 하나 또는 둘 이상의 데이터 구조로 이루어져 있다면 Model과 View를 동기화 하는 것이 어려워 집니다. (Model 외에도 UI는 State에 의존합니다)

예로, component는 toggle이 off이면 hidden해야 되고, button은 text field가 비어 있다면 유효해서는 안됩니다. AppKit과 UIKit에서 구현되어진 MVC 패턴은 View와 Controller가 별도로 구분된 엔티티가 아니기 때문에 다소 색다릅니다. 대신에 이것을 View Controller로 불리는 하나의 엔티티로 합쳤습니다.

  • UI와 Model을 동기화 하는 작업의 필요성
  • State와 UI는 항상 동기화 되지 않음
  • Model을 View에서 SubView로 업데이트 할 수 있어야 하며, 그 반대의 경우도 가능하다.
  • 이 모든것은 오류나 버그가 발생하기 쉬운 문제가 있다.

A functional user interface

SwiftUI의 아름다운 점은 User Interface가 Functional 하다는 것입니다.

상황을 어렵게 만드는 중간 상태가 없으며, 특정 조건에 따라 어떤 View가 보여줘야 할지 검사할 필요가 없으며, 상태 변화가 있을 때 사용자 인터페이스의 일부를 수동으로 refresh할 필요가 없어집니다.

또한 [weak self]를 사용하여 클로저의 참조 순환을 피해야 한다는 부담에서 해방 되는 것이 가능합니다. 왜냐하면 View는 값 타입이고 Capture는 참조가 아닌 복사본을 사용하여 이루어지기 때문입니다.

다음과 같은 특징이 있습니다.

  1. Declaractive : 사용자 인터페이스를 구현하는 것이 아닌 선언합니다.
  2. Functional : 같은 State에서 UI는 항상 같게 렌더링 됩니다. 다른 말로 UI는 State의 기능을 합니다.
  3. Reactive : State가 변경되었을 때, SwiftUI는 자동적으로 UI를 업데이트 합니다.

State

좀 더 자세히 State를 살펴보자.

var body: some View {
    Button(action: {
        self.numberOfAnswered += 1 // Error
    })
}
// body안에 프로퍼티를 수정하여 view의 상태를 변경할 수 없기 때문에 오류가 뜬다.

// 구조체로 감싸도 에러가 뜬다. 구조체는 값 타입이기 때문이다. 
struct State {
    var numberOfAnswered = 0
}
var state = State()

var body : some View {
    Button(action: {
        self.state.numberOfAnswered += 1 // Error
    })
}

Embedding the state into a class

위의 구조체를 클래스로 변경한다면 에러는 사라질 것이다. 하지만 App을 실행하면 프로퍼티 값은 병경되지만 view는 업데이트 되지 않는다는 것을 알 수 있다. UIKit을 사용했을 때는 이것이 예상되는 일이다. Model이 변화하였을 때 사용자 인터페이스와 연관된 부분을 업데이트 하는것은 개발자의 몫이기 때문이다.

Wrap to class, embed to sturct

구조체와 클래스를 제거하고 Model에 따라 UI가 업데이트 되는 방법은 무엇일까요 ?

값 타입인 구조체는 작동하지 않는다. 이를 수정하려면 mutability해야 하지만 body는 구조체를 포함할 수 없습니다.

mutating 없이 업데이트 하기 위해서는 간단히 mutating property를 참조 타입으로 묶으면 됩니다. (즉 , 클래스)

class Box<tT> {
    var wrappedValue: T
    init(initialValue valeu: T) {
        self.wrappedValue = value
    }
} 
// 위와 같이 한다면 어떤 타입도 클래스 안에 wrap 할 수 있다.

//정상적으로 작동한다.
struct State {
    var numberOfAnswered = Box(initialValue: 0)
}
var state = State()

var body: some View {
    Button(action: {
        self.state.numberOfAnswered.wrappedValue += 1 
    })
}

다른 인스턴스를 가리키는 경우에만 변경한다. property가 가리키는 인스턴스를 업데이트 해야한다.

The real State

SwiftUI의 struct와 비슷하게 State를 대체해야 한다. 이번에는 SwiftUI에 있는 State 구조체를 사용해서 변경해보자. 그리고 이 State 인스턴스의 value에 접근하고 싶다면 wrappedValue 프로퍼티의 값으로 접근해야 한다.

var _numberOfAnswered = State<Int>(initialValue: 0)

 var body: some View {
    Button(action: {
        self._numberOfAnswered.wrappedValue += 1
        print("Answered: \(self._numberOfAnswered)")
    }) {
        HStack {
            Text("\(self._numberOfAnswered.wrappedValue)/\(numberOfQuestions)")
                .font(.caption)
                .padding(4)
            Spacer()
        }
    }
}

property wrapper 타입은 SwiftUI에 의해 관리되어지는 read and write 할 수 있는 값을 말한다. 이는 위에서 작성한 Box와 비슷하지만 view가 이것을 모니터링 할 수 있는 추가적인 기능이 존재한다. wrapped value가 바뀌면 SwiftUI는 그 값을 이용하여 view를 re-render한다.

그렇다면 State와 @State 어트리뷰트의 $ operator 사이의 관계는 무엇일까요

@State 어트리뷰트로 선언된 프로퍼티는 property wrapper 입니다. 컴파일러는 이름 앞에 _ 를 붙이고 State 타입으로 실제 구현을 하는 형식임을 알 수 있다.

결국 둘은 똑같이 실행됩니다. 이것을 통해 @State 어트리뷰트를 적용하여 state property를 생성한다면 SwiftUI 덕분에 property의 변화에 따라 뷰는 반응하게 되고, 그 프로퍼티의 참조들을 가지고 있는 view는 refresh 된다.

State 변수는 UI 업데이트를 트리거하는데 유용할 뿐만 아니라 값이 바뀌는 것을 update할 때 도 유용합니다.

How binding is (not) handled in UIKit

UIKit/AppKit에서 textfield나 textview가 사용자에게 입력 받은 text를 보여주고 읽을 수 있게 하는데 text property를 사용합니다. UIComponents들은 사용자가 입력하여 보여지는 데이터를 text property에 소유한다고 할 수 있습니다.

값이 바뀔때 알수 있기 위해 우리는 delegate나 값이 바뀌는 이벤트가 발생할 때 생기는 notification을 subscribe 해야한다. 또한 사용자 입력에 유효성을 체크하고 싶다면 text가 바뀔때마다 항상 호출되는 메소드를 사용하여야 한다.

Owning the refrence, not the data

SwiftUI는 위의 과정을 더 간단하게 수행합니다.

이것은 declarative한 접근법을 사용하고 state 프로퍼티가 변경될 때 사용자 인터페이스를 자동으로 업데이트 하게 됩니다.

SwiftUI에서 component들은 데이터를 소유하지않고, 데이터가 저장되어 있는 참조 값을 가지게 된다

어떤 component가 모델을 참조하는지 알기 때문에 모델이 변경될 때 사용자 인터페이스의 어느 부분을 업데이트 해야 되는지 파악이 가능하다. 이것을 위해 참조를 정교하게 다룰 수 있는 binding을 사용합니다.

binding은 데이터를 저장하는 프로퍼티와 데이터의 변화에 따라 이를 보여주는 view 간의 two-way connection(양방향)입니다. 데이터를 직접 저장하는 대신에 source of truth에 property를 연결합니다.

Defining the single sourth of truth

데이터는 하나의 엔티티만이 소유해야 하며, 다른 모든 엔티티들은 이것을 복사하는 것이 아닌 같은 데이터를 접근해야 한다는 것이다.

State를 바꿀 때 사용자 인터페이스에 자동으로 작용되도록 하고 싶기 때문에 값 타입으로는 UI상태를 처리하지 않으려고 합니다

이는 데이터가 참조타입이라면 가능한 부분입니다. @State로 표시된 프로퍼티는 사실 State 이며 value타입입니다. 이것을 메소드로 전달할 때, 이것은 사실 복사값을 전달합니다.

state 프로퍼티는 데이터를 소유해야 하기 때문에 data의 복사값을 전달하여 원래의 데이터와 복사 데이터는 달라집니다. SwiftUI에서 @State 프로퍼티를 복사함으로써, 여러가지의 source of truth를 가지게 됩니다. 이는 여러개의 source of untruth를 얻는것과 같습니다.

The art of observation

이제 여러 프로퍼티로 구현된 모델이 있으며 이를 상태 변수로 사용하려는 경우를 생각했을 때 model을 구조체처럼 값 타입으로 구현하게 되면 작동하지만 효율성이 떨어지게 된다.

model의 프로퍼티를 변경할 때, 우리는 그 프로퍼티를 참조하는 UI만 업데이트 하려고 합니다. 지금은 변경 되었을 때 struct 인스턴스 전체가 참조를 가지는 곳에서 refresh되어 업데이트 됩니다.

이것은 구조체를 사용하는것이 안좋다기 보다는, 같은 모형에 관계없는 프로퍼티를 넣는 것을 피해야 한다는 것입니다.

그러므로 우리 모델은 다음과 같아야 합니다.

  1. 참조 타입이여야 합니다.
  2. UI 업데이트를 트리거해야 하는 property를 지정할 수 있습니다.

이를 위해 3가지 타입이 필요 합니다.

  1. class를 observable하게 선언합니다. 이것은 state 프로퍼티들과 비슷하게 사용됩니다.
  2. class 프로퍼티를 observable하게 선언합니다.
  3. Observable한 class 타입의 인스턴스를 프로퍼티로 선언해야 합니다. 이것은 observable한 클래스를 뷰에서 observed된 프로퍼티로 사용하는 것이 가능하게 한다.

클래스를 observable하게 만들기 위해 우리는 ObservableObject를 준수하게 해야한다. 클래스는 publisher가 됩니다.

프로토콜은 자동으로 합쳐지는 하나의 objectWillChange 프로퍼티를 정의해야 한다. (이는 컴파일러가 하는 역할입니다.)

Sharing in the environment

SwiftUI에서는 가방에서 가지고 있다가 필요할 때 마다 꺼내서 쓰는 방식과 비슷한 방법을 제공합니다.

이 가방은 environment라고 불리며 객체는 environment object라고 불립니다.

이 패턴은 가장 인기있는 SwiftUI의 2가지 방법에서 사용됩니다. (modifier, attribute)
1. environmentObject(_:)를 사용하여, environment에 객체를 주입하는 것이 가능합니다.
2. @EnvironmentObject를 사용하여, environment에서 객체를 꺼내오고 이것을 프로퍼티로 저장하는 것이 가능합니다.

한번 environment를 객체에 주입하면, 이것은 view 또는 subview에도 접근이 가능합니다. view의 parent나 상위 뷰에는 접근이 불가합니다.

rootView에 주입하기 위해 SceneDelegate에서 작업하는 코드

window.rootViewController = UIHostingController(
        rootView: StarterView()
                    .environmentObject(userManager)
                    .environmentObject(ChallengesViewModel())
)

ChallengesViewModel의 인스턴스를 생성하여 이것을 environmnet에 주입하고 있습니다. 그 결과로 StarterView의 계층 구조에 있는 모든 view들은 이 인스턴스에 접근이 가능합니다. 이제 ChallengsViewModel의 인스턴스를 생성했던 것을 바꿔줘야 합니다.

@ObservedObject var challengesViewModel = ChallengesViewModel()

@EnvironmentObject var challengesViewModel: ChallengesViewModel

위와 같이 변경하여 이 프로퍼티는 view의 envrionment로 부터 가져오는 ChallengesViewModel 인스턴스임을 나타내주는 것이 가능하다. (더 이상 초기화 하지 않아도 x)

만약 read-only 프로퍼티라 할당이 불가능한 경우에 Binding에서는 이렇게 immutable한 값들을 binding 할 수 있게 하는 constant() 전역 메소드를 가지고 있습니다.

PractiveView(challengeTest:
    $challengesViewModel
    .currentChallenge, userName:
    $userManager.profile.name,
    numberOfAnswered:
    .constant(challengesViewModel
    .numberOfAnswered))

정리

  • @State를 사용하여 프로퍼티를 생성하고 이는 값이 바뀌면 이 프로퍼티를 사용하는 UI는 자동적으로 re-render 한다.
  • ObservedObject를 준수하는 클래스의 인스턴스로 @ObservedObject 프로퍼티를 생성 가능합니다. 클래스는 하나 또는 그 이상의 @Published 프로퍼티들을 정의 가능합니다. 이러한 변수는 state 변수 처럼 작용하지만 이것은 view가 아닌 class로 구현하는 것은 다릅니다.

SwiftUI : TextField

레이블과 값에 대한 바인딩이 있는 TextField를 작성할 수 있습니다. 값이 문자열인 경우, TextField는 사용자가 입력하거나 편집할 때 값을 계속 업데이트 합니다. String 타입이 아닌 경우, return 값을 누르는 등 사용자가 편집을 커밋할 때 값을 업데이트 합니다.

TextField에 텍스트가 커밋되면 내부에 메서드가 호출되게 할 수 있습니다.

@State private var name = ""
    
    var body: some View {
         
        Form {         
          TextField("아이디", text : $name) //$표시 필수
          Text("Your name is \(name)")
          // 글자가 입력될때마다 업데이트 되어서 호출된다. 
            
        }
    }

KeyboardType

이 수정자를 사용하여 TextField를 탭 했을 때 사용 가능한 키보드 종류를 바꿔주는 방법도 있습니다. TextField(~~~).keyboardType(.decimalPad) 이는 숫자 키패드를 보여줍니다.

Styling Text Fields

SwiftUI는 플랫폼에 적합한 모양과 동작을 반영하는 기본 TextField Style을 제공합니다.

FieldStyle(_:) 수정자를 사용하여 TextField의 모양과 상호 작용을 사용자 정의 할 수 있으며, TextFieldStyle의 인스턴스를 통과할 수 있습니다.

자동수정 비활성화

SwiftUI TextField는 기본적으로 자동 수정이 활성화 되어있습니다. 비활성화를 하려면 disableAutocorrection()과 같이 수정자를 사용하면 됩니다.

정수 또는 소수

숫자를 표시하려면 NumberFormatter를 사용해야 합니다. 사용예시는 다음과 같습니다.

 let formatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        return formatter
    }()
 
    var body: some View {
        VStack {
            TextField("점수", value: $score, formatter: formatter)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding()

대문자 또는 소문자

TextField View는 일반적으로 사용자가 원하는 대소문자로 텍스트를 작성할 수 있습니다. 이것을 사용하기 위해서는 textCase() 수정자를 사용해야 합니다.

  • .textCase(.lowercase) - 소문자
  • .textCase(.uppercase) - 대문자
  • .textCase(.none) - 사용자 지정

텍스트 작성 후 키보드 닫기

SwiftUI 에는 키보드를 숨기는 방법에 대한 간단한 수정자가 없기 때문에 UIKit에서 사용했던 것을 가져와야 합니다 .

UIApplication.shared.sendAction(#selector(UIResponder.resignFirstReponder),
to: nil, from: nil, for: nil)

위 코드는 "키보드 사용을 중지하도록 제어할 수 있는 모든 항목에게 요청" 이라고 말하는것과 같은 코드이다. TextField가 활성화 되면 키보드가 해제됩니다.

해당 코드는 읽기 쉽지 않으므로 다음과 같이 확장하여 래핑하는 것을 고려하는것도 좋은 방법이다.

#if canImport(UIKit)
extension View {
    func hideKeyboard() {
        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}
#endif

SwiftUI3 (iOS 15+) 에서 나온 방법

@FocusState를 사용하여 해결할 수 있습니다. Done을 결합하여 키보드 바로 위 버튼을 추가함

@State private var username: String = ""
@State private var password: String = ""

@FocusState private var focusedField: Field?

var body: some View {
    NavigationView {
        Form {
            TextField("Username", text: $username)
                .focused($focusedField, equals: .username)
            SecureField("Password", text: $password)
                .focused($focusedField, equals: .password)
        }
        .toolbar {
            ToolbarItem(placement: .keyboard) {
                Button("Done") {
                    focusedField = nil
                }
            }
        }
    }
}

텍스트 저장

TextField에 텍스트를 입력 후 저장할 수 있도록 함수를 만들어주겠습니다.

@State var showTextField: String = ""
@State var itemArray: [String] = []

var body: some View {}

func saveItem() {
    itemArray.append(showTextField)
    showTextField = ""
}

이 코드를 버튼의 액션에 추가해주면 텍스트가 화면에 저장되게 됩니다.


참조

https://minosaekki.tistory.com/31

https://seons-dev.tistory.com/4

https://stackoverflow.com/questions/56491386/how-to-hide-keyboard-when-using-swiftui

profile
hi there 👋

0개의 댓글