Post

Swift 예제: iOS + WatchOS 연동 앱 LinkedCounter (주요 개발 과정 요약)

Swift 예제: iOS + WatchOS 연동 앱 LinkedCounter (주요 개발 과정 요약)

Swift 예제: iOS + WatchOS 연동 앱 LinkedCounter

전체 코드

 

개발 환경

  • Xcode 13.3.1, Swift 5
  • iOS App with Watch App 템플릿
  • Storyboard Interface (워치 앱에 부분적으로 SwiftUI 사용)

 

목적

  • 워치용 앱 기본 개발 학습
  • 아이폰 앱 워치 앱 간 데이터 연동 방법 학습
  • 컴플리케이션(Complication) 기본 개발 학습

 

필수 참고 글

 

이 글은 설명글이 아니고 개인적인 복습을 위해 주요 내용을 요약한 글입니다. 애플 워치 개발에 관한 설명글은 위의 글들을 번역해서 따로 올리도록 하겠습니다.

 

동작 내용

  • 기본적으로 버튼을 누르면 숫자가 증감하는 카운터 앱입니다.
  • 모든 데이터는 아이폰 앱에서 UserDefaults로 관리됩니다.
  • 애플 워치 앱은 아이폰과 연결해 아이폰 내부의 UserDefaults 정보를 통신으로 받아오고 정보를 표시합니다.
  • 아이폰의 전원이 꺼지지 않는 한 앱이 백그라운드 모드이거나 꺼져있어도 서로 정보를 통신하고 동기화할 수 있습니다.

 

  • 아이폰 앱에서 숫자를 증감시키면 애플 워치 앱에 실시간으로 반영됩니다.

http://www.giphy.com/gifs/joWqzA74ih34OYPjN2

 

  • 반대로 애플워치 앱에서 숫자를 증감시켜도 아이폰 앱에서 실시간으로 반영됩니다.
  • 이 때 통신상태 불량으로 인한 데이터 불일치를 방지하기 위해 싱크 검증 기능이 있습니다.

http://www.giphy.com/gifs/Gay28hfApiqpELQ7Kk

(시뮬레이터는 통신 속도가 느려 싱크 과정에서 딜레이가 있습니다.)

 

  • 현재 카운트 수 및 현재 카운트 수 / 목표 수치의 진행율을 표시하는 컴플리케이션 기능(CLKComplicationTemplateCircularSmallRingText)이 있습니다.

http://www.giphy.com/gifs/mgZnzfzjFvdZTpEZlz

1000으로 목표 수치 설정

 

http://www.giphy.com/gifs/4U2ZIdxnTkaPtPwWI7

현재 카운트 수 / 목표 수치 = 450 / 1000 = 45% 분량의 진행율이 프로그레스 링으로 표시

 

http://www.giphy.com/gifs/7Yb7hHjaeWGMVqvRrE

카운트 수가 1000이 넘으면 1K로 표시 (CLKComplicationTemplateCircularSmallRingText의 표시 자리수 한계 방지를 위함)

 

주요 과정 정리

1) 사전 작업
  • 시뮬레이터에서 아이폰 디바이스와 워치 모델을 연결시켜야 합니다.

 

2) 아이폰 앱 개발
  • 먼저 애플 워치를 고려하지 않고 아이폰 단독 앱을 개발합니다. 스토리보드 구조는 아래와 같습니다.

 

3) 워치 앱 UI 디자인
  • 다음, 워치 앱을 개발합니다.
  • 워치 앱은 개발 요소에 제약이 있습니다.
    • UIView가 없고, 추가할 수 있는 UI Component 종류의 수도 적습니다.
    • Constraint 개념이 없고, 요소를 수직으로만 쌓을 수 있습니다.
    • @IBAction 연결 시 sender 파라미터가 없습니다.
    • 그 외 레이블로부터 텍스트를 가져올 수 없고 설정만 가능한 것 등
  • 워치 부분(WatchKit)은 AppExtension으로 나뉘어 있습니다.
    • WatchKit App은 스토리보드처럼 UI 표시 부분만 담당합니다.
    • WatchKit Extension은 컨트롤러가 모여있는 부분으로 Inerface, Notification, Complication 등의 데이터를 관리하고 UI 측(WatchKit App)에 전달합니다.
  • Label과 Button만 이용해 아래 UI 요소를 Interface Controller Scene에 추가합니다.
    • Interfaces는 앱을 열었을 때 바로 첫 화면을 표시하는 부분입니다.
    • 여기서 UI 컴포넌트를 추가하고, 연동 작업은 WatchKit Extension의 InterfaceController.swift에서 작성합니다.

 

  • InterfaceController.swift 중 UI 관련된 부분 코드
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import WatchKit
import Foundation
import WatchConnectivity
import ClockKit

class InterfaceController: WKInterfaceController {
    
    enum LabelStatus {
        case stepValue, normal, error
    }
    
    @IBOutlet weak var lblTotalCount: WKInterfaceLabel!
    @IBOutlet weak var lblStatus: WKInterfaceLabel!
    @IBOutlet weak var btnMinus: WKInterfaceButton!
    @IBOutlet weak var btnPlus: WKInterfaceButton!

    private var totalCount: Double!
    private var plusCount: Double!
    
    private var initTotalCountLoaded = false
    private var initPlusCountLoaded = false
    
    override func awake(withContext context: Any?) {
        // Configure interface objects here.
    }
    
    override func willActivate() {
        // This method is called when watch view controller is about to be visible to user
    }
    
    override func didDeactivate() {
        // This method is called when watch view controller is no longer visible    }
    
    // MARK: - UI Helper
    
    private func turnAllButton(_ isEnable: Bool) {
        btnPlus.setEnabled(isEnable)
        btnMinus.setEnabled(isEnable)
    }
    
    private func setCountLabel(_ text: String, color: UIColor = .white) {
        lblTotalCount.setTextColor(color)
        lblTotalCount.setText(text)
    }
    
    private func setStatus(_ status: LabelStatus, _ text: String) {
        switch status {
        case .stepValue:
            lblStatus.setTextColor(.white)
            lblStatus.setText("Step Value: \(text)")
        case .normal:
            lblStatus.setTextColor(.white)
            lblStatus.setText(text)
        case .error:
            lblStatus.setTextColor(.red)
            lblStatus.setText("Error: \(text)")
        }
    }
    
    // MARK: - @IBActions
    
    @IBAction func btnActRefresh() {
       fetchDataFromRootDevice()
    }
    
    @IBAction func btnActMinus() {
        print(#function)
        // 시계 디스플레이는 plusCount 만큼 일단 올리고
        // 맞으면 확정 표시
        // 틀리면 롤백
        guard let plusCount = plusCount else { return }
        totalCount = totalCount - plusCount
        setCountLabel(totalCount.intText, color: .darkGray)
    }
    
    @IBAction func btnActPlus() {
        print(#function)
        // 시계 디스플레이는 plusCount 만큼 일단 올리고
        // 맞으면 확정 표시
        // 틀리면 롤백
        guard let plusCount = plusCount else { return }
        totalCount = totalCount + plusCount
        setCountLabel(totalCount.intText, color: .darkGray)
        setStatus(.normal, "Syncing...")
    }
}
  • 시뮬레이터로 빌드 및 실행합니다. 기능은 동작하지 않지만 버튼 및 레이블은 정상적으로 표시되어야 합니다.

 

4) 아이폰 앱 - 워치 앱 통신 구현

 

애플 워치에서 아이폰으로 요청

  • InterfaceController.swift의 멤버 변수 및 willActivate()에 다음 코드를 추가합니다.
    • WCSession은 아이폰 워치가 통신할 수 있도록 하는 매개체입니다.
    • willActivate() 안에는 앱을 열었을 때 실행해야할 동작을 지정합니다.
1
2
3
4
5
6
7
8
9
10
11
12
// 1: Session property
private var session = WCSession.default

override func willActivate() {
    // This method is called when watch view controller is about to be visible to user
    
    // 2: Initialization of session and set as delegate this InterfaceController if it's supported
    if WCSession.isSupported() {
        session.delegate = self
        session.activate()
    }
}

 

  • 먼저 워치의 InterfaceController.swift에서 아이폰 앱에 요청(request)을 보내고 응답(response)을 받는 코드를 작성합니다..
  • session.sendMessage(...) 메서드를 이용합니다.
    • ["request": "totalCount_get"]이라는 메시지를 아이폰 앱으로 보냅니다.
1
2
3
func sendMessage(_ message: [String : Any], 
                   replyHandler: (([String : Any]) -> Void)?, 
                   errorHandler: ((Error) -> Void)? = nil)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
session.sendMessage(makeRequest("totalCount_get")) { response in
    if let totalCount = response["response"] as? Double {
        self.totalCount = totalCount
        self.setCountLabel(totalCount.intText)
        self.initTotalCountLoaded = true
        // Set CurrentData
        CurrentData.shared.currentTotalCount = totalCount
        if self.initPlusCountLoaded {
            self.turnAllButton(true)
        }
    } else {
        self.setCountLabel("ERROR", color: .red)
    }
} errorHandler: { error in
    print("Error sending message: %@", error)
    self.setCountLabel("ERROR", color: .red)
}

 

  • 다음 아이폰에서 워치측의 요청을 처리하는 코드를 작성합니다.
  • 아이폰 프로젝트에 SessionHandler.swift 파일을 생성하고 아래 코드를 추가합니다.
  • WCSessionDelegateextension 안에 있는 func session(...didReceiveMessage...) 부분이 요청을 처리하는 곳입니다.
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import Foundation
import WatchConnectivity

class SessionHandler : NSObject, WCSessionDelegate {
    
    // 1: Singleton
    static let shared = SessionHandler()
    
    // 2: Property to manage session
    var session = WCSession.default
    
    override init() {
        super.init()
        
        // 3: Start and activate session if it's supported
        if WCSession.isSupported() {
            session.delegate = self
            session.activate()
        }
        
        print("isPaired?: \(session.isPaired), isWatchAppInstalled?: \(session.isWatchAppInstalled)")
    }
    
    func isSupported() -> Bool {
        WCSession.isSupported()
    }
    
    // MARK: - WCSessionDelegate
    
    // 4: Required protocols
    
    // a
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        print("activationDidCompleteWith activationState:\(activationState) error:\(String(describing: error))")
    }
    
    // b
    func sessionDidBecomeInactive(_ session: WCSession) {
        print("sessionDidBecomeInactive: \(session)")
    }
    
    // c
    func sessionDidDeactivate(_ session: WCSession) {
        print("sessionDidDeactivate: \(session)")
        // Reactivate session
        /**
         * This is to re-activate the session on the phone when the user has switched from one
         * paired watch to second paired one. Calling it like this assumes that you have no other
         * threads/part of your code that needs to be given time before the switch occurs.
         */
        self.session.activate()
    }
    
    /// Observer to receive messages from watch and we be able to response it
    ///
    /// - Parameters:
    ///   - session: session
    ///   - message: message received
    ///   - replyHandler: response handler
    func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
        guard let request = message["request"] as? String else {
            return
        }
        
        switch request {
        case "plusCount_get":
            replyHandler(makeResponse(localStorage.double(forKey: .cfgPlusCount)))
        case "totalCount_get":
            replyHandler(makeResponse(localStorage.double(forKey: .cfgTotalCount)))
        case "totalCount_plus":
            let oldValue = localStorage.double(forKey: .cfgTotalCount)
            let plusCount = localStorage.double(forKey: .cfgPlusCount)
            let newValue = oldValue + (plusCount > 0 ? plusCount : 5.0)
            localStorage.set(newValue, forKey: .cfgTotalCount)
            replyHandler(makeResponse(newValue))
            NotificationCenter.default.post(name: .refreshView, object: nil)
        case "totalCount_minus":
            let oldValue = localStorage.double(forKey: .cfgTotalCount)
            let plusCount = localStorage.double(forKey: .cfgPlusCount)
            let newValue = oldValue - (plusCount > 0 ? plusCount : 5.0)
            localStorage.set(newValue, forKey: .cfgTotalCount)
            replyHandler(makeResponse(newValue))
            NotificationCenter.default.post(name: .refreshView, object: nil)
        case "targetCount_get":
            replyHandler(makeResponse(localStorage.double(forKey: .cfgTargetCount)))
        default:
            break
        }
    }
    
    func sendDataForComplication() {
        print(#function, session.isReachable)
        if session.isReachable {
            session.transferCurrentComplicationUserInfo(["request": "forComplication"])
        }
    }
    
}

  • ["request": "totalCount_get"]라는 메시지를 받은 아이폰 앱은
    • guard let request = message["request"] 를 통과하고
    • switch request문에서 "totalCount_get" 부분을 찾은 뒤
    • replyHandler([String:Any])를 이용해 워치 앱 측으로 ["response": 현재카운트] 응답을 보냅니다.
    • 해당 응답을 받은 워치 앱은 응답 내용을 바탕으로 워치 앱에 정보를 표시합니다.

 

아이폰에서 애플 워치로 요청

  • 데이터는 전부 아이폰에서 관리하기 때문에 아이폰에서 애플워치르 보낼 때엔 request만 필요하고 response는 받을 필요가 없습니다.

 

  • 아이폰 프로젝트의 뷰 컨트롤러 클래스 안에 멤버변수로 다음을 추가합니다.
1
2
// 1: Get singleton class whitch manage WCSession
var connectivityHandler = SessionHandler.shared

 

  • 요청이 필요한 순간에 session.sendMessage(…replyHandler…)를 사용합니다.
    • 예) 버튼을 눌렀을 때
1
2
3
connectivityHandler.session.sendMessage(makeRequestForSendToWatch(totalCount: stepperTotalCount.value, plusCount: stepperPlusCount.value), replyHandler: nil) { error in
    print("Error sending message: \(error)")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
func makeRequestForSendToWatch(totalCount: Double, plusCount: Double, targetCount: Double? = nil) -> [String: Any] {
    var dict = [
        "totalCount": totalCount,
        "plusCount": plusCount,
    ]
    
    if let targetCount = targetCount {
        dict["targetCount"] = targetCount
    }
    
    return dict
}

 

  • 애플 워치의 인터페이스 컨트롤러의 WCSessionDelegate 확장 안에 아이폰으로부터 받은 요청을 처리하는 코드를 작성합니다.
  • 요청값을 받으면, 그 값으로 레이블을 최신 정보로 업데이트합니다.
  • 만약 아이폰으로 응답을 보낼 필요가 있다면, WCSessionDelegate.session(_:didReceiveMessage:replyHandler:).를 사용합니다.
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
// Receive messages from iPhone
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
    // 1: We launch a sound and a vibration
    WKInterfaceDevice.current().play(.notification)

    if let totalCount = message["totalCount"] as? Double {
        self.totalCount = totalCount
        setCountLabel(totalCount.intText)
    }
    
    if let plusCount = message["plusCount"] as? Double, self.plusCount != plusCount {
        self.plusCount = plusCount
        setStatus(.stepValue, plusCount.intText)
    }
    
    if let targetCount = message["targetCount"] as? Double, CurrentData.shared.targetCount != targetCount {
        CurrentData.shared.targetCount = targetCount
        // reloadComplicationTimeline()
        
        self.setStatus(.normal, "Target: \(targetCount.intText)")
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
            self.setStatus(.stepValue, self.plusCount.intText)
        }
    }
}

 

통신 과정이 추가된 InterfaceController.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import WatchKit
import Foundation
import WatchConnectivity
import ClockKit

struct CurrentData {
    
    static var shared = CurrentData(currentTotalCount: nil, targetCount: nil)
    
    var currentTotalCount: Double?
    var targetCount: Double?
}

class InterfaceController: WKInterfaceController {
    
    enum LabelStatus {
        case stepValue, normal, error
    }
    
    @IBOutlet weak var lblTotalCount: WKInterfaceLabel!
    @IBOutlet weak var lblStatus: WKInterfaceLabel!
    @IBOutlet weak var btnMinus: WKInterfaceButton!
    @IBOutlet weak var btnPlus: WKInterfaceButton!
    
    // 1: Session property
    private var session = WCSession.default
    
    private var totalCount: Double!
    private var plusCount: Double!
    
    private var initTotalCountLoaded = false
    private var initPlusCountLoaded = false
    
    override func awake(withContext context: Any?) {
        // Configure interface objects here.
    }
    
    override func willActivate() {
        // This method is called when watch view controller is about to be visible to user
        
        // 2: Initialization of session and set as delegate this InterfaceController if it's supported
        if WCSession.isSupported() {
            session.delegate = self
            session.activate()
        }
        
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [unowned self] timer in
            print("timer", self.session.isReachable)
            if session.isReachable {
                fetchDataFromRootDevice()
                timer.invalidate()
            } else {
                print(#function, "iPhone is not reachable!!")
            }
        }
    }
    
    override func didDeactivate() {
        // This method is called when watch view controller is no longer visible
        reloadComplicationTimeline()
        
    }
    
    // MARK: - UI Helper
    
    private func turnAllButton(_ isEnable: Bool) {
        btnPlus.setEnabled(isEnable)
        btnMinus.setEnabled(isEnable)
    }
    
    private func setCountLabel(_ text: String, color: UIColor = .white) {
        lblTotalCount.setTextColor(color)
        lblTotalCount.setText(text)
    }
    
    private func setStatus(_ status: LabelStatus, _ text: String) {
        switch status {
        case .stepValue:
            lblStatus.setTextColor(.white)
            lblStatus.setText("Step Value: \(text)")
        case .normal:
            lblStatus.setTextColor(.white)
            lblStatus.setText(text)
        case .error:
            lblStatus.setTextColor(.red)
            lblStatus.setText("Error: \(text)")
        }
    }
    
    // MARK: - Handle Watch's system
    
    private func requestChangeTotalCount(_ request: String, completionHandler: @escaping () -> (), failedHandler: @escaping () -> ()) {
        if session.isReachable {
            session.sendMessage(makeRequest("totalCount_\(request)")) { response in
                
                if let totalCount = response["response"] as? Double {
                    self.totalCount = totalCount
                    self.setCountLabel(totalCount.intText)
                    // Set CurrentData
                    CurrentData.shared.currentTotalCount = totalCount
                    print("Request success:", totalCount)
                    completionHandler()
                } else {
                    print("Request failed:", #line)
                    failedHandler()
                }
            } errorHandler: { error in
                print("Error sending message: %@", error)
                failedHandler()
            }
        } else {
            print("Request failed:", #line)
            failedHandler()
        }
    }
    
    private func fetchDataFromRootDevice() {
        if session.isReachable {
            turnAllButton(false)
            
            setCountLabel("...")
            setStatus(.normal, "Loading status...")
            
            session.sendMessage(makeRequest("plusCount_get")) { response in
                if let plusCount = response["response"] as? Double {
                    self.plusCount = plusCount
                    self.setStatus(.stepValue, plusCount.intText)
                    self.initPlusCountLoaded = true
                    if self.initTotalCountLoaded {
                        self.turnAllButton(true)
                    }
                } else {
                    self.setStatus(.error, "Unknown")
                }
            } errorHandler: { error in
                print("Error sending message: %@", error)
                self.setStatus(.error, error.localizedDescription)
            }
            
            session.sendMessage(makeRequest("totalCount_get")) { response in
                if let totalCount = response["response"] as? Double {
                    self.totalCount = totalCount
                    self.setCountLabel(totalCount.intText)
                    self.initTotalCountLoaded = true
                    // Set CurrentData
                    CurrentData.shared.currentTotalCount = totalCount
                    if self.initPlusCountLoaded {
                        self.turnAllButton(true)
                    }
                } else {
                    self.setCountLabel("ERROR", color: .red)
                }
            } errorHandler: { error in
                print("Error sending message: %@", error)
                self.setCountLabel("ERROR", color: .red)
            }
            
            session.sendMessage(makeRequest("targetCount_get")) { response in
                if let targetCount = response["response"] as? Double {
                    CurrentData.shared.targetCount = targetCount
                }
            }
        }
    }
    
    private func reloadComplicationTimeline() {
        if let complication = CLKComplicationServer.sharedInstance().activeComplications?.first {
            CLKComplicationServer.sharedInstance().reloadTimeline(for: complication)
        }
    }
    
    // MARK: - @IBActions
    
    @IBAction func btnActRefresh() {
       fetchDataFromRootDevice()
    }
    
    @IBAction func btnActMinus() {
        print(#function)
        // 시계 디스플레이는 plusCount 만큼 일단 올리고
        // 맞으면 확정 표시
        // 틀리면 롤백
        guard let plusCount = plusCount else { return }
        totalCount = totalCount - plusCount
        setCountLabel(totalCount.intText, color: .darkGray)
        setStatus(.normal, "Syncing...")
        
        requestChangeTotalCount("minus") { [self] in
            setStatus(.stepValue, plusCount.intText)
            // Set CurrentData
            CurrentData.shared.currentTotalCount = totalCount
        } failedHandler: { [self] in
            totalCount = totalCount + plusCount
            setCountLabel(totalCount.intText)
        }
    }
    
    @IBAction func btnActPlus() {
        print(#function)
        // 시계 디스플레이는 plusCount 만큼 일단 올리고
        // 맞으면 확정 표시
        // 틀리면 롤백
        guard let plusCount = plusCount else { return }
        totalCount = totalCount + plusCount
        setCountLabel(totalCount.intText, color: .darkGray)
        setStatus(.normal, "Syncing...")
        
        requestChangeTotalCount("plus") { [self] in
            setStatus(.stepValue, plusCount.intText)
            // Set CurrentData
            CurrentData.shared.currentTotalCount = totalCount
        } failedHandler: { [self] in
            totalCount = totalCount - plusCount
            setCountLabel(totalCount.intText)
        }
    }
}

extension InterfaceController: WCSessionDelegate {
    
    // MARK: - WCSessionDelegate
    
    // 4: Required stub for delegating session
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        print("activationDidCompleteWith activationState:\(activationState) error:\(String(describing: error))")
    }
    
    // Receive messages from iPhone
    func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
        // 1: We launch a sound and a vibration
        WKInterfaceDevice.current().play(.notification)
    
        print(#function, message)
        if let totalCount = message["totalCount"] as? Double {
            self.totalCount = totalCount
            setCountLabel(totalCount.intText)
        }
        
        if let plusCount = message["plusCount"] as? Double, self.plusCount != plusCount {
            self.plusCount = plusCount
            setStatus(.stepValue, plusCount.intText)
        }
        
        if let targetCount = message["targetCount"] as? Double, CurrentData.shared.targetCount != targetCount {
            CurrentData.shared.targetCount = targetCount
            // reloadComplicationTimeline()
            
            self.setStatus(.normal, "Target: \(targetCount.intText)")
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
                self.setStatus(.stepValue, self.plusCount.intText)
            }
        }
    }
    
    func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
        print("received UserInfo:", userInfo)
    }
}

 

5) 컴플리케이션 제작
  • 컴플리케이션은 굉장히 많은 종류가 있는데, 시간 제한상 CLKComplicationTemplateCircularSmallRingText 한 가지만 제작합니다.
  • 컴플리케이션은 ComplicationController.swift에서 다루게 됩니다.
  • 컴플리키에션 구현에는 복잡한 특징이 있습니다.
    • 컴플리케이션의 변화 내용을 앱이 실행된 때에 미리 타임라인 형태로 예약해서 등록합니다.
    • 애플 워치의 과도한 작업으로 인한 성능 저하 및 배터리 누수를 막기 위해 특정 시간동안 표시할 수 있는 내용을 100회로 제한합니다.
    • 컴플리케이션의 내용에 따라 앱이 실행된 때에만 업데이트가 한 번 필요한 경우 또는 앱이 실행 후 일정 시간동안 컴플리케이션을 여러 번 업데이트해야 하는 경우가 있습니다.
    • 전자의 경우 타임라인을 사용하지 않으며, 타임라인을 사용하지 않더라도 컴플리케이션의 표시 날짜(Date)는 등록해야 합니다.
    • 타임라인을 사용할 경우 타임라인의 종료 시간을 시스템에 미리 알려줘야 됩니다.

 

[caption id=”attachment_4851” align=”alignnone” width=”1038”] 컴플리케이션의 타임라인 예시. 5분 간격으로 업데이트할 정보를 미리 등록합니다.[/caption]

 

 

  • 먼저 CLKComplicationTemplate을 반환하는 함수들을 추가합니다.
    • CLKComplicationTemplate는 실제로 애플 워치 화면에 표시되는 컴플리케이션의 UI 인스턴스입니다.
    • 그래픽이 포함된 컴플리케이션은 SwiftUI의 View를 이용합니다. 예제 코드는 다음과 같습니다.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      
        case .graphicCircular:
                    return CLKComplicationTemplateGraphicCircularView(ComplicationViewCircular(current: current, target: target))
              
        // ================ Swift UI 부분 ================ //
              
        import SwiftUI
        import ClockKit
              
        struct ComplicationViewCircular: View {
            @State var current: Double
            @State var target: Double
                  
            var body: some View {...}
        }
              
      
    • CLKComplicationTemplate를 나중에 컴플레이케이션 표시 시간을 정하는 딜리게이트 메소드에서 지정합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
extension ComplicationController {
    
    func makeTemplate(image: UIImage, complication: CLKComplication) -> CLKComplicationTemplate? {
        switch complication.family {
        case .circularSmall:
            return CLKComplicationTemplateCircularSmallRingImage(imageProvider: CLKImageProvider(onePieceImage: image), fillFraction: 0.0, ringStyle: .closed)
        default:
            return nil
        }
    }
    
    func makeTemplate(current: Double, target: Double, complication: CLKComplication) -> CLKComplicationTemplate? {
        let percentage = current / target
        
        switch complication.family {
        case .circularSmall:
            return CLKComplicationTemplateCircularSmallRingText(textProvider: CLKTextProvider(format: current.rationalizedText), fillFraction: Float(percentage), ringStyle: .closed)
        default:
            return nil
        }
    }
}

 

기본 컴플리케이션 표시

  • 다음 func getCurrentTimelineEntry(...withHandler...)에 기본 컴플리케이션을 추가합니다.
1
2
3
4
5
6
7
8
9
10
func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {
    // Call the handler with the current timeline entry
    
    if let template = makeTemplate(image: UIImage(systemName: "star.fill")!, complication: complication) {
        let entry = CLKComplicationTimelineEntry(date: Date(), complicationTemplate: template)
        handler(entry)
    } else {
        handler(nil)
    }
}
  • CLKComplicationTimelineEntry
    • 특정 시간대의 타임라인에 진입해 컴플리케이션을 표시합니다.
    • 기본 컴플리케이션은 별표(★)만 표시합니다.
    • date에는 현재 순간을 의미하는 Date()를 입력합니다.
  • handler(entry)를 실행되는 순간 컴플리케이션이 표시됩니다.

 

업데이트된 정보의 컴플리케이션 표시

  • 미래에 업데이트될 정보는 func getTimeEntries(for...after...limit...)에서 실행합니다.
  • 이 앱에서는 아이폰 앱과의 통신 과정이 있으며, 앱이 실행된 순간 바로 정보를 알 수 있는게 아니고 아이폰으로부터 정보를 받아온 뒤 정보를 알 수 있는데, 그 과정에서 약간의 시간이 소요됩니다.
  • 타이머를 돌려서 데이터가 nil이 아닌 경우(=데이터를 받아온 경우) 그 순간에 업데이트된 컴플리케이션 정보를 미래 엔트리에 등록합니다.
  • 이렇게 하면 앱을 닫은 후에도 컴플리케이션 정보가 업데이트됩니다.

http://www.giphy.com/gifs/4U2ZIdxnTkaPtPwWI7

버튼을 눌러 앱을 닫으면, 처음에는 기본 내용 별표(★)가 표시되었다가 잠시 뒤 정보가 업데이트되면 별표가 사라지고 해당 정보로 업데이트됩니다.

 

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
func getTimelineEntries(for complication: CLKComplication, after date: Date, limit: Int, withHandler handler: @escaping ([CLKComplicationTimelineEntry]?) -> Void) {
    // Call the handler with the timeline entries after the given date

    var dataRead = false

    Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [self] timer in
        
        if dataRead {
            timer.invalidate()
            return
        }
        
        var entries: [CLKComplicationTimelineEntry] = []
        
        if let totalCount = CurrentData.shared.currentTotalCount,
           let targetCount = CurrentData.shared.targetCount,
           let template = makeTemplate(current: totalCount, target: targetCount, complication: complication) {
            let entry = CLKComplicationTimelineEntry(
                date: Date(),
                complicationTemplate: template)
            entries.append(entry)
            
            dataRead = true
            timer.invalidate()
            handler(entries)
        }
    }
}
  • handler(entries)가 실행되는 순간 미래의 타임라인 엔트리가 등록됩니다.
    • 앱 시작 후 바로 등록될 필요는 없으며, 시간차를 두고 등록해도 됩니다. (예: 네트워크 서버로부터 비동기로 데이터를 받아왔을 때 실행)

 

  • 마지막으로, 시스템에 타임라인 종료 시점을 알려줍니다.
1
2
3
4
5
func getTimelineEndDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) {
    // Call the handler with the last entry date you can currently provide or nil if you can't support future timelines
    let after = Date().timeIntervalSince1970 + (60 * 60) * 60
    handler(Date(timeIntervalSince1970: after))
}
  • 이 메소드의 목적은 다른 앱들로부터 컴플리케이션 스케줄을 분석해 시스템의 자원 사용량을 미리 계산해 최적화하기 위함이라고 합니다.
    • 이 시간이 지나면 업데이트 작업을 수행하지 않습니다.
  • 앱 실행 후 한 시간 뒤를 타임라인 종료 시점이라고 알려줍니다.
    • 이 앱은 타임라인의 종료 시간을 알 수 없기 때문에 한 시간이라고 하였지만, 보통은 타임라인의 마지막 시간을 입력합니다.
  • 타임라인이 존재하지 않으면 handler(nil)을 입력합니다.

 

앱을 닫을 때마다 타임라인 업데이트

  • 타임라인은 홈 버튼을 눌러 앱을 닫는 시점마다 갱신되어야 합니다. 인터페이스 컨트롤러 코드의 didDeactivate() 부분에 다음 내용을 추가합니다. (reloadTimeline은 WatchOS 9 이후 Deprecate 예정)
    • CLKComplicationServer.sharedInstance().reloadTimeline(for: complication)
  • if문으로 내용의 변경이 있을때만 업데이트를 실행하도록하면 시스템 자원을 덜 사용할 수 있습니다.
This post is licensed under CC BY 4.0 by the author.