How to workaround URLSession issue in watchOS 6.1.1

Issue #577

https://stackoverflow.com/questions/59724731/class-avassetdownloadtask-is-implemented-in-both-cfnetwork-and-avfoundation

1
2
3
objc[45250]: Class AVAssetDownloadTask is implemented in both /Applications/Xcode.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CFNetwork.framework/CFNetwork (0x4ddd0ec) and /Applications/Xcode.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/AVFoundation.framework/AVFoundation (0x16aea494). One of the two will be used. Which one is undefined.

objc[45250]: Class AVAssetDownloadURLSession is implemented in both /Applications/Xcode.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CFNetwork.framework/CFNetwork (0x4dddd44) and /Applications/Xcode.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/AVFoundation.framework/AVFoundation (0x16aea4bc). One of the two will be used. Which one is undefined.

Then URLSession stops working.

1
2020-01-13 22:50:12.430920+0100 MyAppWatch WatchKit Extension[45250:2099229] Task <3CECDE81-59B9-4EDE-A4ED-1BA173646037>.<1> finished with error [-999] Error Domain=NSURLErrorDomain Code=-999 "cancelled" UserInfo={NSErrorFailingURLKey=https://myapp.com/def.json, NSErrorFailingURLStringKey=https://myapp.com/def.json, NSLocalizedDescription=cancelled}

The workaround is to remove Combine based API, and use completion block.

Instead of dataTaskPublisher which hangs indefinitely, no sink is reported

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
URLSession.shared
.dataTaskPublisher(for: url)
.map({ $0.data })
.decode(type: T.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
.receive(on: RunLoop.main)
.sink(receiveCompletion: { completionStatus in
switch completionStatus {
case .finished:
break
case .failure(let error):
completion(.failure(error))
}
}, receiveValue: { value in
completion(.success(value))
})

just use normal

1
2
3
4
5
6
7
8
9
let task = URLSession.shared.dataTask(with: url, completionHandler: { data, response, error in
if let data = data, let model = try? JSONDecoder().decode(T.self, from: data) {
completion(.success(model))
} else {
completion(.failure(error ?? ServiceError.noInternet))
}
})

task.resume()

How to show list with section in SwiftUI

Issue #511

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
struct CountriesView: View {
let groups: [Group]

init(countries: [Country]) {
self.groups = CountryManager.shared.groups(countries: countries)
}

var body: some View {
List {
ForEach(groups) { group in
Section(
header:
Text(group.initial)
.foregroundColor(Color.yellow)
.styleTitle(),
content: {
ForEach(group.countries) { country in
CountryRow(country: country)
}
}
)
}
}
}
}

How to make Swift Package Manager package for multiple platforms

Issue #504

https://twitter.com/NeoNacho/status/1181245484867801088?s=20

There’s no way to have platform specific sources or targets today, so you’ll have to take a different approach. I would recommend wrapping all OS specific files in #if os and just having one target. For tests, you could do something similar, one test target, but conditional tests

Every files are in Sources folder, so we can use platform and version checks. For example Omnia is a Swift Package Manager that supports iOS, tvOS, watchOS, macOS and Catalyst.

For macOS only code, need to check for AppKit and Catalyst

https://github.com/onmyway133/Omnia/blob/master/Sources/macOS/ClickedCollectionView.swift

1
#if canImport(AppKit) && !targetEnvironment(macCatalyst)

For SwiftUI feature, need to check for iOS 13 and macOS 10.15

https://github.com/onmyway133/Omnia/blob/master/Sources/SwiftUI/Utils/ImageLoader.swift

1
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)

How to make multiline Text in SwiftUI in watchOS

Issue #482

lineLimit does not seem to work, use fixedSize instead

Fixes this view at its ideal size.

A view that fixes this view at its ideal size in the dimensions given in fixedDimensions.

1
2
3
4
5
6
7
8
9
extension Text {
func styleText() -> some View {
return self
.font(.footnote)
.foregroundColor(.gray)
.lineLimit(10)
.fixedSize(horizontal: false, vertical: true)
}
}

How to use Swift package manager in watchOS

Issue #474

SPM

Go to Project -> Swift Packages, add package. For example https://github.com/onmyway133/EasyStash

Select your WatchKit Extension target, under Frameworks, Libraries and Embedded Content add the library

CocoaPods

If we use CocoaPods, then it needs to be in WatchKit Extension

1
2
3
4
target 'MyApp WatchKit Extension' do
use_frameworks!
pod 'EasyStash', :git => 'https://github.com/onmyway133/EasyStash'
end

How to reload data without using onAppear in SwiftUI in watchOS

Issue #468

From onAppeear

Adds an action to perform when the view appears.

In theory, this should be triggered every time this view appears. But in practice, it is only called when it is pushed on navigation stack, not when we return to it.

So if user goes to a bookmark in a bookmark list, unbookmark an item and go back to the bookmark list, onAppear is not called again and the list is not updated.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import SwiftUI

struct BookmarksView: View {
let service: Service
@State var items: [AnyItem]
@EnvironmentObject var storeContainer: StoreContainer

var body: some View {
List(items) { item in
makeItemRow(item: item)
.padding([.top, .bottom], 4)
}
.onAppear(perform: {
self.items = storeContainer.bookmarks(service: service).map({ AnyItem(item: $0) })
})
}
}

So instead of relying on UI state, we should rely on data state, by listening to onReceive and update our local @State

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct BookmarksView: View {
let service: Service
@State var items: [AnyItem]
@EnvironmentObject var storeContainer: StoreContainer

var body: some View {
List(items) { item in
makeItemRow(item: item)
.padding([.top, .bottom], 4)
}
.onAppear(perform: {
self.reload()
})
.onReceive(storeContainer.objectWillChange, perform: { _ in
self.reload()
})
}

private func reload() {
self.items = storeContainer.bookmarks(service: service).map({ AnyItem(item: $0) })
}
}

Inside our ObservableObject, we need to trigger changes notification

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final class StoreContainer: ObservableObject {
let objectWillChange = PassthroughSubject<(), Never>()

func bookmark(item: ItemProtocol) {
defer {
objectWillChange.send(())
}
}

func unbookmark(item: ItemProtocol) {
defer {
objectWillChange.send(())
}
}
}

How to use EnvironmentObject in SwiftUI for watchOS

Issue #467

Declare top dependencies in ExtensionDelegate

1
2
3
4
5
6
7
class ExtensionDelegate: NSObject, WKExtensionDelegate {
let storeContainer = StoreContainer()

func applicationDidEnterBackground() {
storeContainer.save()
}
}

Reference that in HostingController. Note that we need to change from generic MainView to WKHostingController<AnyView> as environmentObject returns View protocol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class HostingController: WKHostingController<AnyView> {
var storeContainer: StoreContainer!

override func awake(withContext context: Any?) {
super.awake(withContext: context)
self.storeContainer = (WKExtension.shared().delegate as! ExtensionDelegate).storeContainer
}

override var body: AnyView {
return AnyView(MainView()
.environmentObject(storeContainer)
)
}
}

In theory, the environment object will be propagated down the view hierarchy, but in practice it throws error. So a workaround now is to just pass that environment object down manually

Fatal error: No ObservableObject of type SomeType found
A View.environmentObject(_:) for StoreContainer.Type may be missing as an ancestor of this view

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
struct MainView: View {
@EnvironmentObject var storeContainer: StoreContainer

var body: some View {
VStack {
List(services.map({ AnyService($0) })) { anyService in
NavigationLink(destination:
ItemsView(service: anyService.service)
.navigationBarTitle(anyService.service.name)
.onDisappear(perform: {
anyService.service.requestCancellable?.cancel()
})
.environmentObject(storeContainer)
) {
HStack {
Image(anyService.service.name)
.resizable()
.frame(width: 30, height: 30, alignment: .leading)
Text(anyService.service.name)
}
}
}.listStyle(CarouselListStyle())
}
}
}

How to create watch only watchOS app

Issue #457

From Creating Independent watchOS Apps

The root target is a stub, and acts as a wrapper for your project, so that you can submit it to the App Store. The other two are identical to the targets found in a traditional watchOS project. They represent your WatchKit app and WatchKit extension, respectively.

How to show web content as QR code in SwiftUI in watchOS

Issue #449

WatchKit does not have Web component, despite the fact that we can view web content

A workaround is to show url as QR code

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

struct QRCodeView: View {
let title: String
let url: URL

var body: some View {
GeometryReader { geometry in
VStack {
self.makeImage(size: geometry.size)
.padding(.top, 10)
Text("Scan to open")
.font(.system(.footnote))
}.navigationBarTitle(self.title)
}
}

private func makeImage(size: CGSize) -> some View {
let value = size.height - 30
return RemoteImage(url: self.url)
.frame(width: value, height: value, alignment: .center)
}
}

How to load remote image in SwiftUI

Issue #448

Use ObservableObject and onReceive to receive event. URLSession.dataTask reports in background queue, so need to .receive(on: RunLoop.main) to receive events on main queue.

For better dependency injection, need to use ImageLoader from Environment

There should be a way to propagate event from Publisher to another Publisher, for now we use sink

ImageLoader.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
import Combine
import WatchKit

class ImageLoader: ObservableObject {
private var cancellable: AnyCancellable?
let objectWillChange = PassthroughSubject<UIImage?, Never>()

func load(url: URL) {
self.cancellable = URLSession.shared
.dataTaskPublisher(for: url)
.map({ $0.data })
.eraseToAnyPublisher()
.receive(on: RunLoop.main)
.map({ UIImage(data: $0) })
.replaceError(with: nil)
.sink(receiveValue: { image in
self.objectWillChange.send(image)
})
}

func cancel() {
cancellable?.cancel()
}
}

RemoteImage.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
import SwiftUI
import WatchKit

struct RemoteImage: View {
let url: URL
let imageLoader = ImageLoader()
@State var image: UIImage? = nil

var body: some View {
Group {
makeContent()
}
.onReceive(imageLoader.objectWillChange, perform: { image in
self.image = image
})
.onAppear(perform: {
self.imageLoader.load(url: self.url)
})
.onDisappear(perform: {
self.imageLoader.cancel()
})
}

private func makeContent() -> some View {
if let image = image {
return AnyView(
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
)
} else {
return AnyView(Text("😢"))
}
}
}

How to do navigation in SwiftUI in watchOS

Issue #447

NavigationView is not available on WatchKit, but we can just use NavigationLink

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
List(services.map({ AnyService($0) })) { anyService in
NavigationLink(destination:
ItemsView(service: anyService.service)
.navigationBarTitle(anyService.service.name)
.onDisappear(perform: {
anyService.service.requestCancellable?.cancel()
})
) {
HStack {
Image(anyService.service.name)
.resizable()
.frame(width: 30, height: 30, alignment: .leading)
Text(anyService.service.name)
}
}
}

Adding NavigationLink to a View adds a round shadow cropping effect, which is usually not want we want.

But we shouldn’t wrap Button as Button handles its own touch event, plus it has double shadow effect.

1
2
3
4
5
6
7
NavigationLink(destination:
QRCodeView(title: item.title, url: item.url)
) {
Button(action: {}) {
Text("Open")
}
}

Just use Text and it’s good to go

1
2
3
4
5
NavigationLink(destination:
QRCodeView(title: item.title, url: item.url)
) {
Text("Open")
}