포스트

SwiftUI (4)

HackerNews API를 사용하여 앱 만들기.

1. ListView(TableView)를 사용하여 만들기.

예전에는 Text를 여러개 사용하면 에러가 났던 것 같은데, 현재는 가능하다.

1
2
3
4
5
6
7
8
struct ContentView: View {
    var body: some View {
        List{
            Text("Hello, world!")
            Text("Hi!")
        }
    }
}

이렇게 리스트를 추가하니

CleanShot 2024-09-08 at 19 38 45@2x

테이블뷰와 같은 녀석이 나왔다.

2. Navigation View 추가하기

Navigation Bar를 추가함으로써 뒤로돌아가기 용이하게 한다.

1
2
3
4
5
6
7
8
9
10
struct ContentView: View {
    var body: some View {
        NavigationView {
            List{
                Text("Hello, world!")
                Text("Hi!")
            }            
        }
    }
}

지금은 보이지 않는다.

1. Navigation Tilte 설정하기

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
    var body: some View {
        NavigationView {
            List{
                Text("Hello, world!")
                Text("Hi!")
            }
            .navigationBarTitle("H4X0R News")
        }
    }
}

이때 Title의 위치가 중요한데

뷰안에 해줘야 된다. 뷰 밖에 해주면 보이지 않는다.

  • 잘못된 예시
1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
    var body: some View {
        NavigationView {
            List{
                Text("Hello, world!")
                Text("Hi!")
            }
        }
        .navigationBarTitle("H4X0R News")
    }
}

CleanShot 2024-09-08 at 19 52 44@2x

3. 게시글에 관한 struct 구성하기

1
2
3
4
struct Post: Identifiable {
    let id: String
    let title: String
}

이렇게 Post 라는 Struct를 만들어 주었는데 여기서 눈여겨 봐야할 것은 바로 Identifiable 프로토콜이다.

자세한건 Docs 를 참고하자.

CleanShot 2024-09-08 at 20 35 29@2x

간단하게 정리를 하면 ID값이 필요할때 사용한다.

그리고 ID를 가진다는것은 중복값이 없다는 것을 의미한다.

그래서 id라는 변수나 상수가 없을때는 아래와 같이 에러가 뜬다.

CleanShot 2024-09-08 at 20 39 16@2x

4. posts라는 배열의 변수를 만들어 ListView에 출력하기

1
2
3
4
5
let posts = [
    Post(id: "1", title: "Hello"),
    Post(id: "2", title: "Bonjour"),
    Post(id: "3", title: "안녕")
]

이렇게 posts라는 배열을 만들어 주었다.

이제 ListView에 배열의 값을 출력하게 해보자.

CleanShot 2024-09-08 at 20 42 55@2x

List를 입력하면 이렇게 여러가지를 선택할 수 있다.

CleanShot 2024-09-08 at 20 44 02@2x

그러면 다음과 같이 되는데,

data에는 우리가 새롭게 만든 배열을 넣어준다.

rowContent는 Closure 형태를 사용하여 만들어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ContentView: View {
    var body: some View {
        NavigationView {
            List(posts, rowContent: { post in
                Text(post.title)
            })
            .navigationBarTitle("H4X0R News")
        }
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
            List(posts) { post in
                Text(post.title)
            }
            .navigationBarTitle("H4X0R News")
        }
    }
}

두개의 코드는 같은 걸 의미한다. 취향차이

즉 각 줄에 title을 넣어준다 라는 것!

다음과 같이 보여지게 된다.

CleanShot 2024-09-08 at 20 46 41@2x

5. API를 사용하여 값을 가져오기

api는 사이트에서 확인.

1. 가져올 값에 대한 데이터 모델링

PostData라는 파일을 만들고 다음과 같이 해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Results: Decodable {
    let hits: [Post]
}

struct Post: Decodable, Identifiable {
    var id: String {
        return objectID
    }
    let objectID: String
    let points: Int
    let title: String
    let url: String
}

Post는 이전에 했던것과 중복되므로 이전것을 지워주도록 하자.

Identifiable이 있어야 하므로, Computed Property를 사용하여 objectID를 리턴하도록 한다.

2. Network Manager를 사용하여 API를 이용해 값을 가져오기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class NetworkManageL: ObservableObject {
    
    func fetchData() {
        if let url = URL(string: "http://hn.algolia.com/api/v1/search?tags=front_page") {
            let session = URLSession(configuration: .default)
            let task = session.dataTask(with: url) { (data, response, error) in
                if error == nil {
                    let decoder = JSONDecoder()
                    if let safeData = data {
                        do {
                            let results = try decoder.decode(Results.self, from: safeData)
                        } catch {
                            print(error)
                        }
                    }
                }
            }
            task.resume()
        }
    }
    
}

이건 이전에 많이 해봤던 것이라 설명은 패스

하나 다른점이라면 바로 ObservableObject를 사용했다는 점!

그렇다면 ObservableObject란 무엇일까?

1. ObservableObject

ObservableObject는 클래스가 상태 변화를 외부에 알릴 수 있도록 하는 프로토콜이다. ObservableObject를 준수하는 클래스는 상태가 변경되었음을 알리기 위해 SwiftUI에서 제공하는 objectWillChange 프로퍼티를 사용한다. ObservableObject를 채택한 클래스는 뷰와 함께 사용되어, 데이터가 변경될 때마다 해당 뷰를 자동으로 업데이트할 수 있다.

  • 주요 특징
    • 클래스에서만 사용할 수 있다. 구조체에는 사용할 수 없다.
    • ObservableObject 프로토콜을 준수하는 클래스는 상태가 변경되었음을 알리기 위해 SwiftUI 뷰에 바인딩된다.
    • SwiftUI는 ObservableObject 클래스 인스턴스의 변경을 감지하고, 이로 인해 뷰를 다시 렌더링한다.

여기까지 했다면 값을 출력하도록 할 것이다.

class안에 var posts = [Post]() 로 배열을 하나 만들어 주자.

이때 그냥 만드는 것이 아닌 @Published를 붙여주자!

2. Published

사실 이건 Combine을 사용할때 해봤지만 정리를 해본다.

@Published는 ObservableObject 클래스 내에서 상태를 저장하고, 상태가 변경될 때마다 이를 알릴 수 있는 프로퍼티 래퍼이다. @Published 속성이 변경되면 SwiftUI는 이를 감지하고, 해당 속성을 사용하는 뷰를 자동으로 업데이트한다.

  • 주요 특징
    • @Published는 ObservableObject에서만 사용 가능
    • @Published 프로퍼티의 값이 변경될 때마다 objectWillChange를 자동으로 호출하여 구독자에게 알린다.
    • SwiftUI의 데이터 바인딩과 밀접하게 연결되어 있어, 데이터를 쉽게 공유하고 업데이트할 수 있다.

그리고 networkManager 객체를 하나 만들어 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ContentView: View {
    
    @ObservedObject var networkManaer = NetworkManager()
    
    var body: some View {
        NavigationView {
            List(posts) { post in
                Text(post.title)
            }
            .navigationBarTitle("H4X0R News")
        }
    }
}
3. ObservedObject

@ObservedObject는 뷰가 특정 ObservableObject를 관찰하도록 할 때 사용되는 프로퍼티 래퍼이다. @ObservedObject는 부모 뷰가 소유하고 있는 ObservableObject를 자식 뷰에서 사용할 때 주로 사용된다.

  • 주요 특징
    • @ObservedObject를 사용하면 뷰가 해당 객체의 변경 사항을 관찰하고, 객체의 값이 변경될 때마다 뷰가 자동으로 다시 렌더링된다.
    • @ObservedObject는 뷰 내에서 다른 뷰 모델을 공유할 수 있게 해준다.
    • @StateObject와 달리 @ObservedObject는 객체의 생명 주기를 관리하지 않는다.
      • 즉, 뷰가 업데이트될 때마다 객체가 새로 생성되지 않는다.

다시 돌아와서 정리를 해보면.

  1. ContentView가 생성되고 NetworkManager 인스턴스를 @ObservedObject로 초기화.
  2. ContentView가 화면에 나타나면 onAppear를 통해 fetchData() 메서드가 호출됨.
  3. NetworkManager의 fetchData()가 네트워크 요청을 비동기적으로 수행.
  4. 데이터가 성공적으로 수신되면, 메인 스레드에서 @Published var posts를 업데이트.
  5. posts가 변경되면 SwiftUI는 이를 감지하고 ContentView를 다시 렌더링.
  6. 업데이트된 데이터로 List가 다시 렌더링되어 사용자에게 표시됨.
3. NetworkManager의 fetchData를 실행하도록 구현

이렇게 networkManager를 구현했지만, 정작 중요한 fetchData를 실행할 부분은 어디에도 없다.

이제 이부분을 실행하도록 하면 되는데 UIKit에서는 ViewDidLoad 같은 생명주기를 이용해서 앱이 실행될때마다 호출을 하는 방식이 있는데 SwiftUI에서는 앱의 생명주기를 어떻게 표현을 할까?

→ 모든 ContentView에는 onAppear가 있다!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct ContentView: View {
    
    @ObservedObject var networkManaer = NetworkManager()
    
    var body: some View {
        NavigationView {
            List(networkManaer.posts) { post in
                Text(post.title)
            }
            .navigationBarTitle("H4X0R News")
        }
        .onAppear {
            self.networkManaer.fetchData()
        }
    }
}

CleanShot 2024-09-08 at 21 45 53@2x

4. DispatchQueue를 사용하여 결과를 Main Thread에서 작업하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class NetworkManager: ObservableObject {
    
    @Published var posts = [Post]()
    
    func fetchData() {
        if let url = URL(string: "http://hn.algolia.com/api/v1/search?tags=front_page") {
            let session = URLSession(configuration: .default)
            let task = session.dataTask(with: url) { (data, response, error) in
                if error == nil {
                    let decoder = JSONDecoder()
                    if let safeData = data {
                        do {
                            let results = try decoder.decode(Results.self, from: safeData)
                            DispatchQueue.main.async {
                                self.posts = results.hits
                            }
                        } catch {
                            print(error)
                        }
                    }
                }
            }
            task.resume()
        }
    }
    
}

이렇게 값만 해주면 끄읏.

단 url이 없는 경우도 있으므로 옵셔널로 바꿔주자.

let url: String?

6. Hstack을 사용하여 숫자도 표현하기

1
2
3
4
HStack {
                    Text(String(post.points))
                    Text(post.title)
                }

CleanShot 2024-09-08 at 21 51 53@2x

뭐 이건 설명할게 없어서 패스

7. 새로운 뷰를 만들고 list의 row를 클릭시 화면이 전환되게 구현하기

우선 DetailView라는 SwiftUI파일을 하나 만들어 준다.

그리고 url이라는 상수를 하나 만들어 준다.

1
2
3
4
5
6
7
8
struct DetailView: View {
    
    let url: String?
    
    var body: some View {
        Text("Hello, World!")
    }
}

1. Navigation Link를 사용하여 연결하기

CleanShot 2024-09-08 at 22 24 04@2x

이것도 역시 여러 선택지가 있는데, 해당부분을 선택 하도록 하자.

뭐랄까 UIKit의 TableView에서의 didSelectRowAt 같은 느낌으로 보면 될 것 같다.

여기서 destination은 말 그대로 클릭시 전환될 부분이다.

마치 우리가 예전에 TableView를 만들고서, 다음 화면으로 전환을 할때, 새로운 VC를 만들어서 그곳에 데이터를 넘기면서 전환을 하던것으로 생각하면 이해하기가 쉽다.

이전 글을 보며 회상하면 좋을 듯 하다.

거기선 스토리 보드를 사용 하였기에 instantiateViewController를 통해서 VC를 인스턴스화 해주었다.

label은 그 destination에서 보여줄 내용에 관한것을 담아준다.

먼저 label은 엔터를 쳐서 Closure 형태로 만들어 준다.

그리고 그 내용는 이전에 있던 Hstack의 내용을 그냥 이동해주자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ContentView: View {
    
    @ObservedObject var networkManaer = NetworkManager()
    
    var body: some View {
        NavigationView {
            List(networkManaer.posts) { post in
                NavigationLink(destination: DetailView(url: post.url)) {
                    HStack {
                        Text(String(post.points))
                        Text(post.title)
                    }
                }
            }
            .navigationBarTitle("H4X0R News")
        }
        .onAppear {
            self.networkManaer.fetchData()
        }
    }
}

Sep-08-2024 22-33-28

현재 클릭시에는 내용의 변화는 없다.

단지 화면의 변화만 생긴다.

8. DetailView에서 내용을 출력하기

화면의 변화만 생기는 이유는

1
2
3
4
5
6
7
8
struct DetailView: View {
    
    let url: String?
    
    var body: some View {
        Text("Hello, World!")
    }
}

이렇게 우리가 text를 Hello World라고 했기 때문이다

1. uikit의 webView를 사용하여 url을 보여주기

우선 WebKit을 import 해주자.

그리고 WebView라는 새로운 구조체를 만들고 UIViewRepresentable 프로토콜을 따르게 만들자

UIViewRepresentable이란?

SwiftUI에서 UIView 사용 가능하게 해주는 프로토콜이다.

CleanShot 2024-09-08 at 22 40 41@2x

1
2
3
struct WebView: UIViewRepresentable {
    
}

그랬더니 다음과 같은 에러가 발생!

CleanShot 2024-09-08 at 22 37 34@2x

현재 우리가 만들어둔 구조체가 UIViewRepresentable를 수행할 수 없다는 것이다.

이런 에러는 TableView에서 프로토콜을 사용했을때 이러한 에러가 나서 numberOfRowsInSection, cellForRowAt 을 무조건 구현해야하는 것과 같은 맥락으로 이해하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct WebView: UIViewRepresentable {
    
    let urlString: String?
    
    func makeUIView(context: Context) -> WebView.UIViewType {
        
        return WKWebView()
    }
    
    func updateUIView(_ uiView: WKWebView, context: UIViewRepresentableContext<WebView>) {
        
        if let safeString = urlString {
            if let url = URL(string: safeString) {
                let request = URLRequest(url: url)
                uiView.load(request)
            }
        }
    }
    
}

이렇게 코드를 작성한다.

Context는 다음과 같다.

CleanShot 2024-09-08 at 23 02 33@2x

Flow는 다음과 같다.

  1. DetailView가 로드된다.
  2. DetailView에 url이 전달된다.
  3. makeUIView 함수를 통해 WebKit의 WKWebView를 만들게 된다.
  4. 옵셔널 바인딩을 통해 url값이 있으면 view에 url의 내용을 출력한다.

Sep-08-2024 23-06-07

이렇게 로드가 되는것을 볼 수 있다.

9. 코드 리팩토링

WebView가 현재 DetailView.swift 파일에 같이 있기에 코드 관리를 좀 더 편하게 하고자 새롭게 파일을 만들어 준다.

내용만 옮겨주면 끝.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
    
    let urlString: String?
    
    func makeUIView(context: Context) -> WebView.UIViewType {
        
        return WKWebView()
    }
    
    func updateUIView(_ uiView: WKWebView, context: UIViewRepresentableContext<WebView>) {
        
        if let safeString = urlString {
            if let url = URL(string: safeString) {
                let request = URLRequest(url: url)
                uiView.load(request)
            }
        }
    }
    
}

#Preview {
    WebView(urlString: "https://www.google.com")
}
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.