Swift Playgroundsで快適なSwiftUIコーディング環境を #SwiftAdventCalendar

これは Swift Advent Calendar 2019 24日目のエントリーです。
昨日23日は Moto0124 さんの CGAffineTransformを知る でした。

はてなエンジニアAdvent Calendar 2019での Deletable Table with TextField on SwiftUI に続いて、今回も SwiftUI ネタです。

そもそもSwift Playgroundsって?

ここでいう Swift PlaygroundsXcodeに付属のPlaygroundではなく、iPadアプリとしての Swift Playgroundsを指しています。
以降、 Swift Playgrounds は 「iPadアプリのSwift Playground」、 Xcode PlaygroundXcodeに付属のPlaygroundと使い分けます。

ドキュメント: https://developer.apple.com/documentation/swift_playgrounds

SwiftUIをSwift Playgroundsで扱う

SwiftUIのアナウンス直後から、Swift Playground 3.1以降でSwiftUIが実行できることは話題になっていました。

Swift Playgrounds 3.1のVersion History

us: • Build with the SwiftUI framework in new playgrounds you create
jp: • 新しく作成するプレイグラウンドでは、SwiftUIフレームワークを使用してビルドできます

SwiftUIで表示してみる

すでに広く知られている話だとは思うけれど、まずは単純に表示してみましょう。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello")
                .font(.largeTitle)
                .foregroundColor(.primary)
            Text("world")
                .font(.title)
                .foregroundColor(.secondary)
        }
    }
}

let host = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = host

f:id:yutailang0119:20191223171953j:plain
Swift Playgroundsで実行

Xcode Playground用ファイルとしてExportする

便利な使い方として、Xcode Playground用の拡張子 .playground として作成することもできます。
ファイル作成時に Starting Points > Xcode Playground を選択して、始める必要があります。

f:id:yutailang0119:20191223172525j:plain
Starting PointsからXcode Playgroundを選択

ここから作成したファイルをiCloudAirdropmacOSに送り、Xcodeで開くと、実行できます。

f:id:yutailang0119:20191223161553p:plain
Xcode Playgroundで実行

再利用できるViewの作り方

簡単なレイアウトのViewを作ってみるにはこれだけでも十分便利ですが、複雑なレイアウトを作ったり、再利用しやすいViewを作ったりには不向きに思えます。
また、Playground上での作成と言っても、最終的にはアプリ開発にViewを組み込みたいでしょう。

Swift Playgroundsでも、複数のSwiftファイルを扱うことができるので、各パーツのViewを別々のSwiftファイルに分割してみます。
参考: https://developer.apple.com/documentation/swift_playgrounds/structuring_content_for_swift_playgrounds/using_modules_to_share_code_in_a_playground_book

ファイルを追加

左上のファイルアイコンから、ファイルの追加ができます。
今回は3ファイル作成しました。

f:id:yutailang0119:20191223183537j:plain
左上のアイコンから、ファイルを追加

LargeTitleView.swift

import SwiftUI

struct LargeTitleView: View {
    var body: some View {
        Text("Hello")
            .font(.largeTitle)
            .foregroundColor(.primary)
    }
}

TitleView.swift

import SwiftUI

struct TitleView: View {
    var body: some View {
        Text("World")
            .font(.title)
            .foregroundColor(.secondary)
    }
}

Preview.swift

Preview のみ public 修飾子でアクセスできるようにしておきます。

import SwiftUI

public struct Preview: View {
    
    public init() {}
    
    public var body: some View {
        VStack {
            LargeTitleView()
            TitleView()
        }
    }
}

main

Swift Playgroundのmainファイルでは、PreviewUIHostingController で描画するようにします。
Swift Playgroundsでのmainと、その他のファイルとでは、別Module扱いになるため、 public 修飾子ないものにはアクセスできません。
import はいらない。

import SwiftUI
import PlaygroundSupport

let preview = Preview()
let host = UIHostingController(rootView: preview)
PlaygroundPage.current.liveView = host

f:id:yutailang0119:20191223185219j:plain
実行結果

これ以降はPreviewと各Viewを書き換えるだけで、実行ができるようになりました。
こうすることで、各View毎のstructをそれぞれファイルとして分割できたり、 public 修飾子を書き忘れたり、 init() 実装し忘れで実行できなかったりがなくなります。
iCloud経由で、Xcodeのアプリプロジェクトに追加もできるので、非常に便利ですね。
もちろん、copy&pasteするだけでも、動きます。

Xcode Playgroundで実行する時には注意点があって、Xcode Playground上でのファイルリンクは特殊で、struct定義から直接ファイルジャンプはできません。
上部のペインから、辿ってください。

f:id:yutailang0119:20191223184833p:plain
Jump to Definitionでは、ファイルジャンプできない

まとめ

iPad Proを買ってからというもの、Swift Playgroundsを使いたいと思うことが幾度とありましたが、やっと活用方法を見つけた気がします。
当たり前ですが、iCloudでファイルを同期する場合は、macOSで共有ディレクトリを開いていたりすると、最新の変更が更新されず、消えてしますこともあるので、ご注意を!
また、まだまだ不安定なのか、たまに .playground/Contents.swft が破損して、開けなくなることもありました。 *1

おまけ

このエントリー用の検証していて、以下のコードを書けることに気づいた。
なにか使い道ないかな。

import SwiftUI

public struct Preview<Body: View>: View {
    
    private let _body: Body
    
    public init(body: Body) {
        self._body = body
    }
    
    public var body: some View {
        _body
    }
}

let preview = Preview(body: ContentView())

*1:復帰方法は、macOSでパッケージの中のファイルをサルベージして、新しいPlaygroundとして作成し直す他ないようです...