에러라기보단, 기술을 이해 없이 사용하여 마주한 문제 해결 정도로 봐야겠다.


문제 정의

비동기 함수를 통한 동작으로 UI반영이 안된다.

 

위치정보를 담당하는 locationManager 객체가 @Published로 정의되어 있다.

따라서 locationManager의 값이 변경이 되면 swiftUI는 자동으로 화면을 새로 그려낸다.

따라서 나는 ViewModel에서 locationManager의 속성 변경을 통해 UI를 갱신하도록 하였다.

 

locationManager 객체에는 사용자의 위치를 갱신하는 함수 locationManager(_, didUpdateLocations) 함수가 있다.

lastLocation 속성을 변경하는 동작이다. 해당 함수는 delegate 패턴으로 작성되며, 개발자는 해당 함수를 직접 호출할 수 없다.

그러면 어떻게 사용하느냐?

 

locationManager객체의 startUpdatingLocation() 함수를 통한 대리 호출만 가능하다.

해당 함수는 ViewModel에 정의하여, 여기서 호출이 일어나면 locationManager의 속성이 바뀌게 되고,

이를 SwiftUI가 자동으로 UI에 반영해 준다.

 

그리고 locationManager에는 사용자의 위치를 기반으로 주소값을 갱신하는 함수가 있다.

lastLocation을 기준으로 주소를 가져오게 된다.

 

이때 든 생각

어차피 따로 쓸 일 없는데, 호출 한 번으로 사용자 위치 갱신할 때 한 번에 주소도 갱신하면 안 됨?

 

 

내가 의도한 순서:

위치 갱신 -> 갱신한 위도, 경도를 기준으로 주소 갱신 -> 변경된 속성을 SwiftUI가 감지하여 UI 갱신

 

이런 단순한 생각으로 ViewModel에 다음과 같이 작성하였으나,

사용자 위치만 갱신될 뿐 정작 중요한 주소 레이블이 UI에 반영이 되지 않았다.

 

실제 동작

 

열심히 함수에 print()를 찍어 동작이 어떻게 이루어지다 파악해 보니 내가 작성한 것은 다음과 같이 동작했다.

delegate 패턴으로 작성된 locationManager(_, didUpdateLocation) 함수로 넘어가기 전에 비동기로 주소갱신이 이루어진다.

 

당연하게도 갱신이 되지 않은 위치로 주소를 가져오니 이전 주소가 나오고,

위도, 경도 값이 경신된 이후 함수가 종료된다.

locationManager의 대리호출과 비동기 호출의 조합으로 동작의 순서가 엉망이 돼버린 것.

 


문제 해결

짧지 않은 기간 동안의 삽질과 아직 해결되지 않은 의문점이 남아있지만 아무튼 지금은 의도된 동작대로 작동한다.

 

 

일단 위치갱신과 주소갱신을 한 번에 하겠다는 생각을 버렸다.

따라서 주소갱신함수는 locationManager의 속성을 바꾸는 것이 아닌, Sting을 반환하는 비동기 함수로 변경하였다.

* 비동기로 굳이 작성하는 이유는 주소를 반환하는 reverseDeocoderLocation() 함수가 비동기로 작성되었기 때문.

 

이어서 locationManager에 있던 주소 레이블 속성을 viewModel로 가져왔다.

이렇게 각 함수를 ViewModel에서 따로 호출할 수 있게 변경하였다.

 

이어서 View에서는 onChange를 통해 위도, 경도의 갱신이 완료되면, 주소를 가져오게 하여 동작의 순서를 정해주었다.


정리

동작을 분리한 이유는 다음 한 줄로 정리할 수 있다.

 

UI의 반영은 메인 스레드에서만 이루어진다.

 

ViewModel을 담당하는 FitcastManager@MainActor로 선언된 이유가 바로 여기서 작동하는 동작들은 모두 메인스레드에서 이루어지게 하기 위함이다.

 

 

따라서 기존의 방식대로 주소 갱신이 비동기로 동작하게 된다면 메인스레드가 아닌 새로 생성된 스레드에서 작동하게 되는 것이고, 여기서 이루어진 갱신은 UI에 반영될 수 없다고 판단하였다.

 

따라서 UI에 반영할 주소값의 갱신은 @MainActor로 선언된 ViewModel의 속성으로 두어 비동기로 작동하더라도 메인스레드에서 동작하게끔 수정해 주었다. 

 

사실 해당 문제를 해결하기까지 아직 의문점이 남은 부분이 많이 있다. 아직 aync/await의 사용법과 MainActor와 관련된 이해도가 떨어져서 그렇다고 생각한다. 해당 개념에 대해 쉽게 설명한 글이 많으므로 나중에 완벽하게 이해된다면 다시 한번 리팩토링을 통해 후기로 돌아와야겠다.

 

 

 

 

요청된 (systemMedium) 위젯 패밀리는 현재 위젯 종류에서 지원하지 않습니다.


WidgetKit을 사용하여 위젯 기능을 구현하던 중 마주한 오류다. 시뮬레이터 혹은 실기기를  통해 빌드할 때 테스트하는 환경(scheme)에서 위젯이 지원하지 않는 크기(widget Family)가 요청될 경우 발생하는 오류다. 위젯 관련 기능을 처음 만들다보니 한참을 헤맸다. 

 

struct weatherFitWidget: Widget {
    let kind: String = "WeatherFitWidget"

    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
            weatherFitWidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .contentMarginsDisabled()
        .configurationDisplayName("추천 옷차림")
        .description("현재 기온에 맞는 옷차림을 추천합니다.")
        .supportedFamilies([.systemSmall]) //위젯이 지원하는 크기 종류
    }
}

위젯에는 Small, Medium, Large 크기의 widgetFamily가 있다.

 

마지막 라인의 supportedFamilies() 함수를 통해 지원하는 위젯의 크기를 정해줄 수 있는데, 본인은 작은 사이즈의 위젯만 우선적으로 서비스할 생각이라 .systemSmall 하나만 지원하게 작성하였다.  *여러 개를 지원할 경우 지원하고 싶은 종류를 다 적으면 된다.


Edit Scheme 메뉴

 

작동 환경을 테스트하려고 빌드하게 될 경우, xcode에는 ".systemMedium 사이즈의 위젯을 설치하라" scheme이 작성되어있는데 해당 프로젝트는 .systemSmall 사이즈만 지원을 하니 거기서 문제가 발생한 것. 아마 처음 widgetExtension을 생성하면서 생성되는 기본 scheme으로 추정된다.

 

xcode의 메뉴 중  "Product - Scheme - Edit Scheme" 메뉴로 진입하면 테스트 환경에서 어떤 사이즈의 위젯을 설치할지 설정해 줄 수 있다. 들어가 보니 systemMedium으로 설정이 되어있어서 systemSmall로 바꾸어주니 오류가 사라졌다.

https://github.com/hyun083/CS193p-Spring-2021/tree/main/Assignments/Assignment%202

 

Lecture3와 Lecture4를 시청하고 작성한 두 번째 programming Assignment. More Memorize라는 주제에 맞게 첫 번째 programming Assignment에 몇 가지 task를 추가한 결과물이다.


Required Tasks

  • 테마추가 (제목, 색상, 이모지, 카드쌍의 수)
  • 테마에서 사용가능한 이모지보다 적은 수의 쌍을 생성
  • 쓰이지 않는 이모지가 발생하면 안 된다.
  • 같은 이모지가 두 쌍 이상 생성되면 안 된다.
  • 테마는 최소 6가지 이상 사용해라.
  • 테마의 추가는 한 줄로 가능하다.
  • new game 버튼을 생성해라.
  • new game 생성마다 무작위 테마가 나와야 한다.
  • 게임에 사용되는 카드는 무작위 순서여야 한다.
  • UI에 테마의 이름이 나오게 할 것.
  • 점수를 표시하여 나타내라.

Extra Credit

  • 카드의 색상에 그라데이션 적용
  • 점수에 시간요소를 추가
  • 고정된 수의 카드쌍이 아닌, 매 순간의 랜덤 개수의 카드쌍을 생성해라.
  • 테마 생성 시 카드쌍의 수와 관계없이 초기형태의 모든 이모지 셋이 고정된 상태로 생성되어야 한다.

Lecture 4에서 실습한 코드를 기반으로 EmojiMemoryGame에서 대부분의 요구사항을 만족시킬 수 있었다.

6개의 고정된 이모지 배열과 테마를 표현하기 위한 [(title:String, color:Color, emoji:[String])] 배열을 생성하였다.

 

이후 CreateMemoryGame 함수를 통해 무작위 테마와 순서가 섞인 이모지를 통해 카드쌍을 생성을 통해 요구사항을 만족하였다.


고민이 많았던 요소인 점수, 테마 제목, 테마 색상은 Model을 담당하는 MemoryGame에 작성하였다.

페널티 요소를 다루기 위해 card 구조체에 Bool 타입의 alreadyBeenSeen 속성을 추가하였다.

 

Extra Credit을 위한 시간요소가 기억에 남는다. Date 타입의 timeSinceLastFlip 변수를 생성하여 마지막으로 카드를 뒤집은 시간을 저장한 뒤 max(10 - (카드 쌍을 확인하는 시간 - 마지막으로 카드를 뒤집은 시간), 1)을 보너스 점수로 계산한 뒤 틀렸다면 점수 차감, 쌍이 맞았다면 점수 획득을 통해 게임을 구성하였다.


view를 담당하는 ContentView에는 지난번 assignment 1에서 작성했던 widthThatBestFits() 함수를 재사용하였다. 카드의 수에 맞게 너비를 자동으로 조절해 준다. 

 

추가된 요소인 점수, 제목, 색상은 ViewModel 역할을 하는 EmojiMemoryGame 객체에서 가져오게 되며, New Game 버튼 또한 ViewModel의 CreateMemoryGame() 함수를 작동시켜 새로운 게임을 생성하게 된다.


result

 

[Lecture 4] Memorize Game Logic


mutating

구조체는 클래스와 마찬자기로 메서드를 가질 수 있는데, 자기 자신(self)의 변경이 일어나는 메서드 작성 시 mutating 키워드를 명시해주어야 한다.

 

Lectrue 3에서 구조체의 대표적인 특징인 copy on write이 있다고 했다. 구조체는 값타입(value type)으로 객체의 할당 혹은 인자로 넘겨줄 경우 값 복사가 일어나지만, 정확히는 사본과 원본의 차이점이 발생할 때 복사가 이루어진다고 했다. 여기서 사본생성의 시점 혹은 이후에 사본 생성 여부를 가늠하게 해주는 장치가 바로 mutating 키워드이다.

 

ObservableObject

 

ObservableObject | Apple Developer Documentation

A type of object with a publisher that emits before the object has changed.

developer.apple.com

swiftUI의 핵심기능 중 하나이다. 해당 프로토콜이 적용된 객체에 변경이 감지되면 구독하고 있는 Subscriber에게 변경을 알린다.

가장 많이 사용되는 부분이 MVVM구조의 ViewModel에 해당된다. View에서 ObservableObject를 구독(Subscribe) 하고 ViewModel에서 알리는 변경점을 통해 View의 body의 필요한 부분의 재빌드를 통해 데이터 바인딩이 일어난다.

ObjectWillChange

 

objectWillChange | Apple Developer Documentation

A publisher that emits before the object has changed.

developer.apple.com

ObservableObject를 채택하게 되면 우리는 ObjectWillChange라고 불리는 Publisher를 얻게 된다. 해당 속성을 통해 변경점을 알릴 수 있다.

send()

ObservableObject가 가지고 있는 속성 중 변경이 일어나면, ObjectWillChange.send()를 호출하여 변경사항을 subscriber에게 알리게 된다.

@Published

ObservableObject의 Publisher를 통해 변경점을 알리는 일을 자동으로 해주는 키워드이다. ObservableObject 내에 속성 중에서 변경점을 알리고 싶은 속성에 해당 키워드를 적용하게 되면 변경점이 일어날 경우 자동으로 ObjectWillChange.send()가 호출된다. 주로 ViewModel에 생성된 Model 객체에 사용하게 된다. 따라서 Model의 변경을 자동으로 감지할 수 있고, 이를 통해 Model과 View의 바인딩 작업을 손쉽게 할 수 있다.

@ObservedObject

 

ObservedObject | Apple Developer Documentation

A property wrapper type that subscribes to an observable object and invalidates a view whenever the observable object changes.

developer.apple.com

ObservableObject를 구독(subscribe) 받기 위해 사용하는 키워드이다. 생성된 객체에 해당 키워드를 적용하면 된다. 주로 View에서 ObservableObject를 채택한 ViewModel을 구독하기 위해 사용하게 된다. 이를 통해 ViewModel은 Model과 View의 바인딩이 가능해진다.

 

참고영상

https://www.youtube.com/watch?v=oWZOFSYS5GE&list=PLpGHT1n4-mAsxuRxVPv7kj4-dQYoC3VVu&index=5&t=4804s 

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

[Assignment2] More Memorize  (1) 2023.10.16
[Lecture 3] MVVM and the Swift type system - 2  (0) 2023.07.12
[Lecture 3] MVVM and the Swift type system - 1  (0) 2023.07.08
[Assignment 1] Memorize  (0) 2023.07.06
[Lecture 2] Learning more about SwiftUI  (0) 2023.07.05

[Lecture 3] MVVM and the Swift type system


Generic

'don't care' Type이다.

자료구조를 생성할 때, 정수형이나 문자열 등 타입을 신경 쓰지 않고(don't care) 범용적으로 다루고 싶다면 이때 사용하는 타입이 Generic이다. Generic이 적용된 구조체로는 배열(Array)을 예시로 들 수 있다.

배열의 선언부를 보게 되면 다음과 같은 형태로 구성되어있다고 한다. 저기서 사용되는 ElementGeneric으로서 사용되는 데이터이다.

따라서 배열의 항목을 추가하는 append 메서드에서도 element를 사용하여 타입별로 메서드를 따로 구현하지 않아도 된다.

 

Functions as Type

swift를 접하면서 가장 생소했던 부분이다. swift는 함수를 일급객체로서 사용하는 함수형 프로그래밍 언어이다.

 

일급 객체라는 단어 또한 생소한데, 일급 객체(first-class object)란 다른 객체들에 일반적으로 적용 가능한 연산을 모두 지원하는 객체를 가리킨다. 일급 객체가 되기 위한 조건이 세 가지가 있다.

  • 객체를 변수와 상수에 할당할 수 있다.
  • 객체를 인자(argument)로 넘길 수 있다.
  • 객체를 반환값(return)으로 사용할 수 있다.

swift는 함수를 일급 객체로서 다루는 언어이다. 따라서 swift에서는 함수를 변수 및 상수에 할당, 함수를 다른 함수의 인자로 넘기거나, 함수의 반환값으로 함수를 리턴할 수 있다는 얘기이다. 



Lectrue 3에서는 Functions as Types라는 주제로 함수를 타입으로서 활용하며 변수 및 상수로 사용하는 것에 대해 이야기하였다.

(Int, Int) -> Bool 

두 개의 Int형 매개변수를 받아 Bool 타입의 결괏값을 리턴하는 함수타입

 

(Double) -> Void

Double형 매개변수 하나를 입력받고 리턴값이 없는 함수타입

 

() -> Array <String>

매개변수를 받지 않고 문자열 배열을 리턴하는 함수타입

 

이런 식으로 swift는 함수를 변수 및 상수에 타입으로 선언 및 할당이 가능하며, 함수 그 자체로서 동작하게 되어 다른 변수 및 상수에 결괏값을 전달할 수 있게 된다.

 

closure

"inlined function"

다음으로 함수의 인자로서 함수를 받는다는 것을 언급했다.

LazyVGrid(columns: [GridItem(.adaptive(minimum: 65))], content: {
	return ForEach(emojis[0..<emojiCount], id: \.self){ emoji in
		return CardView(content: emoji).aspectRatio(2/3,contentMode: .fit)
	}
})

Lecture 2에서 보았던 뷰 생성파트를 보면 content라는 인자에 중괄호를 열고 View를 반환하는 코드를 작성하는데, 해당 부분이 바로 함수의 인자로 함수를 받는다고 보면 된다. 단지 우리는 inlined fucntion 형태로 작성하였고, swift에서는 inlined functionclosure(폐쇄)라고 부른다. 어째서 closure라는 이름이 붙은 건지는 다음 Lecture에서 설명할 예정이라고 하니 잘 기억해 두자.

 

Type property

개발하는 데에 있어 전역변수는 사용하기에 편하지만, 의도치 않은 실수를 유발하기에 사용 시 리스크가 크다는 단점이 있다.

피치 못할 사정으로 전역변수를 사용해야 하는 경우가 생기는데, 이럴 때 전역변수로서 동작하지만 타입자체에 귀속시키는 방법이 Type property이다. 

 

class EmojiMemoryGame{
    let emojis = ["🚗","🚕","🚙","🚌","🚎","🏎️","🚓","🚑","🚒","🚐","🛻","🚚",
                  "🚛","🚜","🛵","🏍️","🛺","🚔","🚍","🚘","🚖","✈️","🚤","🛥️"]
    
    func createMemoryGame() -> MemoryGame<String>{
        MemoryGame<String>(number0fPairsOfCards: 4){ pairIndex in
            emojis[pairIndex]	//error!
        }
    }
    
    private var model: MemoryGame<String> = createMemoryGame()
    
    var cards : Array<MemoryGame<String>.Card> {
        return model.cards
    }
}

Lecture 1, 2에 걸쳐 작성된 memorize 데모에 MVVM 디자인 패턴을 적용하기 위한 작업 중 ViewModel 작성 부분이다.

이때 Model 생성 시에 상수로 선언된 emojis 배열을 사용하려 했으나, 오류가 발생한다. 이유는 바로 ViewModel의 인스턴스 생성 시 model이 생성되기 이전에 emojis 배열이 먼저 생성된다는 보장이 없기 때문이다.

 

emojis 배열을 ViewModel 상위로 생성하여 전역변수로서 선언을 해버리면 되겠지만, 개발론적인 시선에서 코드의 품질유지에 적합한 방법이 아니다.

 

이때 사용한 것이 타입 속성(Type Property)이다. 전역변수처럼 사용하고 싶은 속성에 static 키워드를 붙이면 해당 속성을 전역변수처럼 사용할 수 있다. 단, 소속된 타입을 명시해 주어야 하므로 어느 자료와 연관된 속성인지 단번에 유추가 가능하므로 코드의 유지보수에 용이하게 된다.

 

class EmojiMemoryGame{
    static let emojis = ["🚗","🚕","🚙","🚌","🚎","🏎️","🚓","🚑","🚒","🚐","🛻","🚚",
                  "🚛","🚜","🛵","🏍️","🛺","🚔","🚍","🚘","🚖","✈️","🚤","🛥️"]
    
    static func createMemoryGame() -> MemoryGame<String>{
        MemoryGame<String>(number0fPairsOfCards: 4){ pairIndex in
            EmojiMemoryGame.emojis[pairIndex]
        }
    }
    
    private var model: MemoryGame<String> = EmojiMemoryGame.createMemoryGame()
    
    var cards : Array<MemoryGame<String>.Card> {
        return model.cards
    }
}

 

즉 속성자체를 인스턴스가 아닌 타입 그 자체에 저장하는 것과 같다 해서 타입 속성(Type property)라고 부른다. 해당 개념을 알기 전부터 사실 나도 모르게 자주 쓰고 있는 기능이다. 대표적인 예시로 Int.max 와 같이 인스턴스 생성 없이 바로 접근하여 사용되는 속성들이다.

 

참고 영상

https://www.youtube.com/watch?v=--qKOhdgJAs&list=PLpGHT1n4-mAsxuRxVPv7kj4-dQYoC3VVu&index=9 

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

[Assignment2] More Memorize  (1) 2023.10.16
[Lecture 4] Memorize Game Logic - 1  (0) 2023.08.18
[Lecture 3] MVVM and the Swift type system - 1  (0) 2023.07.08
[Assignment 1] Memorize  (0) 2023.07.06
[Lecture 2] Learning more about SwiftUI  (0) 2023.07.05

[Lecture 3] MVVM and the Swift type system


MVVM

Model, View, ViewModel로 구성된 디자인 아키텍처이다. 

지금 배우고 있는 SwiftUIMVVM 디자인 아키텍처를 따르고 있다. 

 

디자인 아키텍처?

상호작용 방식을 명확하게 하는 디자인 패러다임. 간단하게 개발 방법 및 규칙을 정해놓은 것이라고 보면 된다.

 

Model

모델에는 데이터와 로직이 담겨있다. 즉 Memorize 앱에서는 카드 자체와 점수를 계산하는 로직들이 담긴다고 보면 된다.

 

View

사용자에게 모델을 보여주는 부분이다. 

 

ViewModel

모델과 뷰를 바인딩해주는 역할을 한다. 여기서 중요한 점은 뷰모델은 뷰의 존재를 몰라야 한다. 즉 모델의 자료변경이 일어나면 해당 부분에 대해서 공표하기(Publish)만 할 뿐이다. 뷰는 뷰모델의 알림을 감지한 뒤 해당 변경이 자신이 나타내는 정보에 해당하는 사항이라면 새로운 뷰를 그려내어 변경된 부분을 사용자에게 보여준다.

 

반대로 사용자의 의도에 의해 모델의 변경이 일어난다면, 뷰는 곧바로 모델에게 알리는 것이 아니다. 뷰모델이 알아챌 수 있는 함수를 호출하여 뷰모델에게 의도를 전달한다. 뒤이어 뷰모델은 모델에게 변경을 지시하여 모델의 변경이 완료된다. 즉 뷰는 직접적으로 모델에 접근하여 변경을 일으킬 수 없다.

 

전체적인 구조와 이때 사용되는 키워드는 다음과 같다. 각 키워드가 어떻게 동작하는지는 뒤에 이어질 수업에서 나올 예정인가 보다.

 

Structure and Class

구조체와 클래스는 하나의 주제로 묶인 자료의 모음이다.

기본적으로 변수와 상수, 그리고 함수를 정의할 수 있다는 점이 둘의 공통점이지만 둘은 어떤 방식으로 이용하느냐에 따라 큰 차이점이 존재한다.

 

Value type and Reference Type

가장 큰 차이점이 바로 구조체는 값 타입, 클래스는 참조 타입이라는 점이다. 

 

구조체는 할당 혹은 인자로서 넘겨주게 되면 그 자체의 값이 복사되어 사본이 넘어간다. 따라서 해당 부분을 값 타입이라 부른다. 물론 복사가 일어날 때마다 사본이 생성되는 것은 아니고, 사본의 변경이 일어나면 그때서야 복사가 일어난다고 한다. 해당 부분은 copy on wirte이라는 정책으로 불린다.

 

클래스는 할당 혹은 인자로서 넘겨주게 되면 해당 객체를 가리키는 포인터가 생성된다. 여기서 메모리의 힙 영역 관리를 위해 swiftARC 정책에 의해 포인터의 개수를 모니터링하다가 포인터의 증감에 따라 개수가 0이 되면 메모리 해제가 일어나게 된다.

 

Initializer

구조체와 클래스 모두 자동적으로 "무료" 생성자가 주어지게 되는데, 여기서 구조체는 각 인자를 초기화할 수 있는 반면 클래스는 그렇지 못하다고 한다.

 

mutable

구조체의 경우 속성 변경이 일어나는 함수를 작성 시 mutating이라는 키워드를 적는다. copy on wirte 정책으로 인해 해당 부분의 변경이 일어나는지 확인해야 하기 때문이다. 중요한 점은 객체 또한 var로 선언해야 변경이 가능하다.

struct A{
    var value = "a"
    mutating func changeValueTo(newVlaue:String){
        self.value = newVlaue
    }
}

var a = A()

print(a.value)
a.changeValueTo(newVlaue: "A")
print(a.value)

//출력 결과
//a
//A

 

반면 클래스의 경우 속성 자체가 변수로 선언되어 있다면, 객체가 let으로 선언이 되어도 변경이 되는 것을 볼 수 있다.

class B{
    var value = "b"
    func changeValueTo(newVlaue:String){
        self.value = newVlaue
    }
}

let b = B()

print(b.value)
b.changeValueTo(newVlaue: "B")
print(b.value)

//출력 결과
//b
//B

 

Usage

swift의 공식문서를 보면 클래스보단 구조체의 사용을 지양한다고 적혀있다. 여러 가지 이유가 있겠지만, 클래스의 무분별한 사용으로 인해 포인터끼리 참조되는 순환 참조로 인한 메모리 누수를 피하기 위함이 아닐까 생각한다. 

 

Lecture 3에서는 이에 대한 이야기는 언급하지 않았고, swift에서 마주하는 대다수가 구조체이며, 클래스는 ViewModel의 구현에 사용된다고 하였다. Model에 저장된 Data의 바인딩이 주목적이므로 자료의 공유가 빈번하게 일어나기 때문에 포인터를 넘기는 것이 더 효율적이기 때문이라고 생각한다.

 

참고 영상

https://www.youtube.com/watch?v=--qKOhdgJAs&list=PLpGHT1n4-mAsxuRxVPv7kj4-dQYoC3VVu&index=6

 

 

GitHub - hyun083/CS193p-Spring-2021

Contribute to hyun083/CS193p-Spring-2021 development by creating an account on GitHub.

github.com


Lecture 1Lecture 2를 시청하고 주어진 조건에 맞게 Memorize 앱을 작성하면 된다. 열 가지 Required Tasks와  두 가지 Extra Credit이 주어진다.

 

정확한 조건은 CS193p 홈페이지에 올라와있다.


Required Tasks

주 내용은 세 개의 테마 버튼을 생성하여 테마 버튼을 누를 때마다 해당 테마의 이모지로 카드를 바꾸는 것이다.

여기서 포인트는 이모지의 순서를 랜덤 하게 배치하는 것.

 

따라서 3개의 이모지 배열을 생성. 버튼을 누를 때마다 @State 선언된 emojis 배열을 해당 배열로 교체하는 것으로 구성했다.

코드의 재사용을 위해 함수를 작성하였다.

@State var emojis = vehicles
@State var emojiCount = Int.random(in: 4..<vehicles.count)
    
func replaceCard(to arr:[String]){
    emojis = arr.shuffled()
    emojiCount = Int.random(in: 4..<arr.count)
}

함수가 수행될 때마다 emojis 배열이 변경이 되고, 변경이 될 때마다 swift는 교체된 배열을 가지고 새로운 View를 그리게 된다.

여기서 Tasks에 주어진 랜덤 한 순서의 이모지는 Array에 정의되어 있는 shuffled() 함수를 통해 만족하였다.

emojiCount에서 Int.random(in: ) 메서드를 사용했는데, 이 부분은 Extra Credit 부분에서 설명하겠다.

 

HStack{
    Button(action: { replaceCard(to: vehicles) }) {
        Theme(title: "Vehicles", symbol: "car")
    }
	
    Button(action: { replaceCard(to: flowers) }) {
        Theme(title: "Flowers", symbol: "camera.macro")
    }
	
    Button(action: { replaceCard(to: animals) }) {
        Theme(title: "Animals", symbol: "pawprint")
    }
}

struct Theme: View{
    let title: String
    let symbol: String
    
    var body: some View{
        VStack{
            Image(systemName: symbol)
                .font(.largeTitle)
            Text(title)
        }
        .font(.title3)
    }
}

다음으로 작성한 테마 View 구조체. 처음엔 3가지 버튼을 따로 작성하였다가 코드가 지저분해져서 Theme 구조체를 따로 만들었다.

title에는 화면에 보일 테마 이름, symbol은 화면에 보일 테마 이미지를 SF Symbol에 해당하는 이름을 입력받았다.

 


Extra Credit

첫 번째 조건은 테마선택 시 카드의 개수를 랜덤으로 배치하는 것이다.

이때 Int 타입에 정의된 Random(in: ) 타입 메서드를 사용하라고 명시되어 있다. 

random(in:)

Returns a random value within the specified range.

범위를 입력받는 인자를 통해서 명시된 범위 내의 랜덤한 Int를 반환하게 된다.

명시된 조건은 최소 4개의 카드를 보이는 것이기에 4.. <배열크기를 넘겨주었다.

 

두 번째 조건은 바로 화면 크기에 맞추어 카드의 크기를 결정하는 것.

중요한 점은 모든 카드가 배치되었을 때 화면 범위를 벗어나지 않게 끔 적절한 카드 크기를 찾아내는 것이다. (화면이 portrait 상태)

 

여기서 이번 Memorize 앱을 작성하는 데에 가장 긴 시간을 고민했다.

Lecture 2에서 작성된 코드는 LazyVgrid를 통해 고정된 크기의 카드를 나열하였다. 여기서 카드의 수량이 추가되면 카드목록이 화면에 한 번에 잘릴 수 있으니 스크롤 뷰를 통해 카드목록을 탐색할 수 있게 구현하였다.

이번에는 카드의 수량에 맞춰 카드의 크기를 조절해 스크롤 없이 카드목록을 볼 수 있게끔 카드의 크기를 계산하는  widthThatBestFits(cardCount: Int) -> CGFloat 함수를 작성하는 것이 두 번째 조건이었다. 

 

기기가 portrait(세로 방향) 상태일 경우에만 맞추면 된다고 명시되어 있길래 여기서 큰 힌트를 얻었다.

 

카드의 비율은 2:3, 여태 출시된 모든 아이폰의 화면 비율은 카드의 비율보다 세로비율이 더 길게 되어있다. 즉 상단의 제목과 하단의 테마버튼 영역을 제외하고서라도 카드를 정방 배열 형태(2*2, 3*3, 4*4)로 그리드 뷰를 구성한다면 카드와 동일한 2:3의 비율의 그리드뷰를 얻을 수 있고, 해당 뷰를 화면에 넘치지 않게끔 배치가 가능할 것이라 생각하였다.

 

따라서 LazyVgird의 너비를 랜덤 하게 주어지는 (카드목록의 수의 제곱근)으로 나눈 값을 LazyVgird에 들어갈 GridItem의 최소 크기로 지정한다면 SwiftUI는 정방배열의 LazyVgird를 그려내게 된다.

 

func widthThatBestFits(for width: CGFloat, cardCount: Int) -> CGFloat{
    return width/ceil(sqrt(CGFloat(cardCount)))-10
}

따라서 특정 너비와 카드의 수를 입력받아 (카드 수의 제곱근)의 반올림 값으로 특정 너비를 나누는 widthThatBestFits 함수를 구현하였다. 저기서 10을 빼준 것은 카드 간의 간격의 여유를 위해 뺀 것이다.

 

이어서 LazyVgrid의 너비값을 어떻게 가져와야 할지 검색을 해본 결과 GeometryReader라는 Container View를 찾게 되었다.

GeometryReader

A container view that defines its content as a function of its own size and coordinate space.
GeometryReader{ geometry in
	ScrollView{
		let width = geometry.size.width
	}
}

하위뷰의 좌표 정보와 크기 정보를 가져오는 Container View이며 예시와 같은 형태로 size에 정의된 widthheight 속성을 통해 하위 뷰의 너비와 높이에 접근할 수 있다.

 

GeometryReader{ geometry in
	ScrollView{
		let width = widthThatBestFits(for: geometry.size.width, cardCount: emojiCount)
		LazyVGrid(columns: [GridItem(.adaptive(minimum: width))], spacing: 7){
			ForEach(emojis[0..<emojiCount], id: \.self) { emoji in
				CardView(content:emoji)
					.aspectRatio(2/3, contentMode: .fit)
			}
		}
	}
	.foregroundColor(.red)
}

따라서 다음과 같이 GridItem의 최소 크기를 지정하였고, 예상대로 정방 형태의 그리드 뷰를 생성함으로써 portrait 상태에서는 어떠한 기기이든 스크롤 없이 주어진 카드의 전체를 확인할 수 있게 되었다.

 


이렇게 작성된 ContentView.swift 파일의 전체 코드는 다음과 같다.

 

 

[Lecture 2] Learning more about SwiftUI


property initialrize

swift의 변수 및 상수는 초기화 없이 선언만 이루어진 경우, 사용할 수 없다. 따라서 객체 생성 시, 초기값이 주어지지 않은 속성의 경우 반드시 생성자의 인자를 통해 초기값을 할당해주어야 한다.

 

초기값 vs 생성자

그렇다면 초기값이 있는 속성의 경우 생성자를 통해 값을 넘겨주면 어떻게 될까?

당연하게도 생성자로 넘겨준 값으로 할당이 된다. 

 

onTapGesture()

func onTapGesture(
    count: Int = 1,
    perform action: @escaping () -> Void
) -> some View

View의 탭 제스처 감지 함수이다. action에 동작할 클로저를 작성하고, count의 경우 action이 수행될 탭 횟수를 적으면 된다. 기본값은 1이다.

 

@State

A property wrapper type that can read and write a value managed by SwiftUI.

한번 생성된 View는 수정이 불가하다. View가 나타내는 정보를 수정하고 싶을 때는 수정된 정보를 가지고 있는 View를 새로 생성하여 대체하는 것이다. 이때 사용하는 키워드가 @State이다. 변수를 가리키는 포인터로 동작하며, 해당 포인터가 가리키는 변수 값에 변동이 감지된다면, 해당 변수를 참조 중인 View는 새로 그려지게 된다. rxSwift에서 Observable의 역할을 수행하는 키워드이다. 

 

Button

Creates a button that displays a custom label.
init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)

버튼으로 동작하는 View를 생성. actionlabel 두 개의 클로저를 입력받으며,

action에는 버튼이 탭 될 경우 수행될 동작을 정의한다.

labelViewBuilder로 동작하여 View를 나열하는 것으로 버튼의 외형을 정의하면 된다.

 

ForEach

A structure that computes views on demand from an underlying collection of identified data.
struct ForEach<Data, ID, Content> where Data : RandomAccessCollection, ID : Hashable

View 구성시 for문을 사용할 수 없다. 따라서 Collection 타입에 담겨있는 자료들을 표현하기 위해 ForEach를 사용한다.

ForEach()는 자료가 담겨있는 Data, 식별자 역할을 하는 ID, ViewBuilder로 동작하는 Content로 구성된다.

 

순서대로 가져오고 싶은 Collection 타입의 데이터와 해당 자료를 어떤 기준으로 식별할지 나타내는 ID를 지정, 이후 반환될 View를 구성해 주면 된다. Content의 경우 CombinerView와 마찬가지로 ViewBuilder 형태로 동작하기에 단순히 View를 나열하는 것만으로 View를 구성할 수 있게 된다.

 

LazyVgrid

A container view that arranges its child views in a grid that grows vertically, creating items only as needed.
init(
    columns: [GridItem],
    alignment: HorizontalAlignment = .center,
    spacing: CGFloat? = nil,
    pinnedViews: PinnedScrollableViews = .init(), @ViewBuilder content: () -> Content
)

수직형태의 그리드 뷰를 생성할 때 사용하는 View이다. 마찬가지로 수평형태의 그리드 뷰는 LazyHgrid를 통해 만들 수 있다.

columns는 한 열 안에 배치할 View의 간격 및 크기를 지정. 

alignment는 배치될 View 간의 정렬 지정.

spacing의 경우 각 열간의 수직 간격을 지정. 

pinnedViews는 그리드의 섹션별 헤더와 풋터를 지정하는 부분.

contentViewbuilder로써 각 그리드의 요소를 어떤 방식으로 표현할지 정하는 부분이다. 

 

ForEach를 통해 데이터의 묶음을 표현하기 유리한 View이다.

 

SF Symbol

 

SF Symbols - Apple Developer

SF Symbols는 Apple 플랫폼의 시스템 서체인 San Francisco와 매끄럽게 통합되도록 디자인된 아이콘 라이브러리로 약 4,500개의 기호를 포함하고 있습니다.

developer.apple.com

SwiftUI에 관련된 내용은 아니지만 이번 강의를 통해 알게 된 SF Symbol이 인상 깊었다.

apple 플랫폼의 서체와 잘 어울리는 아이콘 라이브러리로써 원하는 아이콘을 찾고, 별도의 저장과정 없이 이름을 통해서 불러낼 수 있다.

 

참고 영상

https://youtu.be/3lahkdHEhW8

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

[Lecture 3] MVVM and the Swift type system - 1  (0) 2023.07.08
[Assignment 1] Memorize  (0) 2023.07.06
[Lecture 1] Getting Started with SwiftUI - 2  (0) 2023.06.22
[Lecture 1] Getting Started with SwiftUI - 1  (0) 2023.06.16
day06_multiTouch  (0) 2022.03.26

+ Recent posts