Help us understand the problem. What is going on with this article?

iOSが無くなってもエンジニアを続けるためのSwiftUI+MacCatalyst入門

メリークリスマス(イヴ)!!🎅🎄

この記事は、第二のドワンゴ Advent Calendar 2019 の24日目の記事です。
そして、今日は12/24、クリスマスイヴです。みなさん、いかがお過ごしでしょうか。

この記事で伝えたい事

この記事は、以下についてまとめています。

  • SwiftUI を使って、動画プレーヤー上にコメントを表示、アニメーションさせる方法
  • Mac Catalyst で、 iOS/iPadOSアプリをMacアプリとして動作させる方法(ここはちょっとだけです💦)

対象読者は、iOS開発の経験があり、SwiftUIをこれから触っていこうとしている人です。

自己紹介

お仕事では、iOSアプリ開発を担当しています。
現在の私の役割としては、プロジェクトの進行管理や、調整ごとがメインなため、どうしても自分で手を動かすことが減ってしまっています。
しかし、その状況に甘える事なく、継続して新しい技術に触れ、技術的な視野・知識を広げていくことがエンジニアとして必要不可欠だと感じています。
というわけで、今回、エンジニアとしてのお勉強の一環として、最近学習したSwiftUIの基本事項をまとめるという形で、アドベントカレンダーに参加させていただこうと思います。よろしくお願いします 🙇🏻‍♂️

ちなみに…
自己紹介ついでに、これまで社内LTで発表したものやAppStoreに公開したものについて触れておくと、
顔認識を使って降り注ぐ自分の顔を避けながら進む、ほうれいせん予防の弾幕ゲームアプリ とか、きれいなジャイアンからヒントを得たARKitを使ってイケメンになれるアプリ とか、とてもここでは説明できないようなちょっとおかしなアプリばかりです…
が、社内のエンジニアの皆さんはそんなふざけたテーマでも暖かく受け止めてくれていましたw
…… 大丈夫です。今回は真面目なテーマで書きますので 💦

こんな感じでした。
F8E20E62-7FB4-4B5F-8DD2-B2468F2A3F62.jpeg683AA08B-9546-4284-B3FC-11A66AA2C644.jpeg

2019年のiOS開発トピックス

さて、iOSの今年のトピックスといえば、なんといっても、
WWDC2019で Mac CatalystSwiftUI が発表されたことです。

Mac Catalyst は、iPad AppをMac Appでも動かせるようにするという画期的な取り組みです。これに伴い、UIKitやAppKitなどのフレームワークが拡張されました。
SwiftUI は、これまでのStoryBoardやXIBを使わずにswiftコードだけでUIが作れるという仕組みです。iOSやMacなどのUIを一括で作成する際にも SwiftUI が使われます。

Mac Catalyst の理由について、Appleは、iOS/iPadOSのAppStoreには多くのアプリが存在しており、それらの多くのアプリがMacでも使えるようになることで、Macユーザがより便利になり、また、デベロッパにも新たな機会を提供できるとしています。1
この取り組みがうまくいけば、 閑散としている MacAppStoreは活性化して、私たちスマホエンジニアは、デスクトップアプリ開発へと、活躍の幅を広げていけることでしょう。そう!これを自在に使いこなすようになれれば、もしiOSが無くなっても、Macアプリ開発者としてやっていける(はず)です!

ということで、今回は、この SwiftUI の基本事項と Mac Catalyst での開発の導入までをまとめていきたいと思います。

動作環境

以下の環境で動作確認しました。

  • macOS Catalina(10.15.2 Beta)
  • xcode11.3 beta

※ ベータ版である理由は特にありません。ベータ版じゃなくても動作するはずです。

開発の準備

SwiftUIで開発を進めるために、まずは、xcodeの新規プロジェクトを作成します。
話を簡単にするために今回は、iOS > SinglePageApplicationを選択します。

ツリーの中から、ContentView.swift を選択します。このファイルがSwiftUIのエントリポイントになります。

Sampleアプリのお題

Sampleプログラムを組みながら、SwiftUIの基礎を把握していきたいと思います。
ドワンゴといえば、niconico 2 です。niconicoは映像の上にコメントが流れることが特徴的な動画/生放送サービスなので、サンプルプログラムもそれにちなんだものにしたいと思います。

今回は、以下のような動画の上にSwiftUIでコメントに見立てたテキストを並べて動かすというものを作ってみたいと思います。

GIFイメージ.gif

映像は、サンタが機械(MACHINE)に溶け込み、光速に飛び込んでいく様子を撮影したものです。
重ねて表示されるテキストは、お寿司です🍣
ハマチがちょっと痛んでしまっています。

SwiftUIの基礎を把握する

UI部分のサンプルコードは次の通りです。

ContentView.swift
import SwiftUI

struct ContentView: View {

    var body: some View {
        PlayerVCView()
        .overlay(GECommentView())
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

まず、SwiftUIの要点について、サンプルコードにも触れつつ、順に説明していきます。

SwiftUIではView構造は構造体(struct)で表現する

上記コードのメインとなるのは、ここです。

strict ContentView: View {
    //...
    var body: some View {
        // ここにViewの構造を定義する
        PlayerVCView()
        .overlay(GECommentView())
    }
    //...
}

ひと目見てわかるとおり、SwiftUIではViewの構造は、構造体(struct)で表現します。

そして、viewは定義するだけでよく、 addSubview() や removeFromSuperview() などの呼び出しは不要です。viewを組みたい構造に合わせて、structを入れ子の構造にしてあげるだけでレイアウトが簡単に組み上がってしまいます。とってもシンプル!

View#overlay() は、view同士を重ね合わせるメソッドです。
たったこれだけで、プレーヤーの上にコメントを載せることができてしまいます。

SwiftUIは、Viewから座標やsizeの取得ができない

なんと、SwiftUIでは、Viewのstructから直接、xy座標やsize(width,height)を取得することができません。
極力、座標指定せずにVStackなどのContainerやalignment、paddingなどを駆使して、座標やサイズ指定せずにレイアウトを組めるのが理想的な形だと思いますが、ときには、座標やサイズが必要なケースもあります。
そういうときのために用意されているのが、GeometryReaderです。(これについては、後述します。)

また、UIViewのように、viewのxy座標とsizeを指定して、配置するということもできません。唯一指定できるのは、offset(x,y) ですが、これは、そのviewの中心の座標をx:0,y:0 として指定します。
それと、viewを配置する際のデフォルトは、親Viewの中心(center)に合わせて配置されるようです。

宣言的なコーディング

SwiftUIでは、各viewのふるまいや見た目などについて、宣言的なコーディングを行います。前述の .overlay().animation() など、.hoge() の形式で記述していきます。最初はとっつきにくいかもしれませんが、慣れてくるときっと読みやすいコードが書けるようになるんじゃないかなと思います。

特徴的なview構造のルール

前述のコードの場合、登場するviewとその親子関係は次のようになります。

親;ルートのview(土台になっているview)
    子:ContentView(bodyのviewと同じsize)
        孫:PlayerVCView(プレーヤーのview)        
            ひ孫:GECommentView(コメントのview)

そして、SwiftUIでは、Viewの位置やsizeは、親ではなく子のViewが決めることになっているようです。

では、ここから PlayerVCViewGCCommentView の中を見ていきたいと思います。

UIViewControllerをSwiftUIから呼び出す方法

一般的な SwiftUI のチュートリアルでは、もっと後のほうに出てくる内容ですが、UIKitを触った事がある人だったら、これを先に見たほうが、 SwiftUI の理解が早まるかなと思うので、先に説明してしまいます。

PlayerVCView (自作struct)は動画を再生してくれるプレーヤーのviewです。iOSで動画再生する場合、 AVKitAVPlayerViewController を使うのが簡単です。シークバーなどのコントローラもよしなに提供してくれます。
もうちょっと複雑な制御をしたい場合、プレーヤーを作り込みたい場合は、 AVPlayer を利用します。

今回は、サンプルプログラムということで、AVPlayerViewController を使いたいと思います。コードは以下のとおりです。

PlayerVCView.swift
import SwiftUI
import UIKit
import AVKit

struct PlayerVCView: UIViewControllerRepresentable {
    typealias UIViewControllerType = AVPlayerViewController

    func makeUIViewController(context: UIViewControllerRepresentableContext<PlayerVCView>) -> AVPlayerViewController {
        let vc = AVPlayerViewController()

        let asset = NSDataAsset(name:"movie")
        let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("movie.mp4")
        try! asset!.data.write(to: url)
        let item = AVPlayerItem(url: url)
        vc.player = AVPlayer(playerItem: item)

        vc.player!.play() // auto play
        return vc
    }

    func updateUIViewController(_ uiViewController: AVPlayerViewController, context: UIViewControllerRepresentableContext<PlayerVCView>) {
        // NOP
    }
}

ViewControllerを SwiftUI から利用する場合、 UIViewControllerRepresentable protocol を実装します。
実装が必要なのは、typealias1つと、次の2つのメソッドです。

  • typealias UIViewControllerType
    • 対象のViewControllerクラスの型を指定します。今回の例では、 AVPlayerViewController になります。
  • makeUIViewController(context: Self.Context) -> Self.UIViewControllerType
    • ViewControllerでいうところの viewDidLoad にあたるメソッドです。ViewControllerの初期化などはここで行います。
  • updateUIViewController(Self.UIViewControllerType, context: Self.Context)
    • viewDidAppear 的なもの? ViewControllerの設定変更などの処理はここで行う。ちなみに、特別な処理がない場合は、空っぽでも問題ありません。

あとは、前述のように、このstructのイニシャライザを、表示したいところで呼び出すだけです。

    PlayerVCView()

UIViewをSwiftUIから呼び出す方法

UIViewを呼び出す方法も紹介しておきましょう。
AVPlayerを利用するケースだと、以下のようなコードになります。

PlayerView.swift
import SwiftUI
import UIKit
import AVKit

struct PlayerView: UIViewRepresentable {
    typealias UIViewType = PlayerUIView

    func makeUIView(context: UIViewRepresentableContext<PlayerView>) -> PlayerView.UIViewType {
        let player = AVPlayer(url: URL(string: "<<動画のURL>>")!)
        player.play()
        let playerView = PlayerUIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100), player: player)
        return playerView
    }

    func updateUIView(_ uiView: PlayerUIView, context: UIViewRepresentableContext<PlayerView>) {
        // NOP
    }
}

class PlayerUIView: UIView {
    private let playerLayer = AVPlayerLayer()

    init(frame: CGRect, player:AVPlayer) {
        super.init(frame: frame)
        self.playerLayer.player = player
        layer.addSublayer(playerLayer)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        playerLayer.frame = bounds
    }
}

AVPlayerはUIViewを直接持っていないので、別途UIViewのクラスを作って、AVPlayerのlayerをaddSublayerし、そのveiwを返すようにします。

UIViewRepresentable protocol を利用しますが、 UIViewControllerRepresentable とほとんどやることは変わりません。

レイアウトを決める

ここからは、 GECommentView の中を解説していきます。
プレーヤー上にコメントを表示し、アニメーションさせるviewです。
実装は次のとおりです。

GECommentView.swift
import SwiftUI

struct GECommentView: View {
    @State private var moveIt = false
    @State private var commentStrs = [
        "マグロ",
        "サバ",
        "ハマチ",
        "たまご"
    ]

    var animation: Animation {
        return Animation.default
    }

    var body: some View {

        let animation = Animation.easeInOut(duration: 1.0)

        return VStack {
            CommentLabelView(text: commentStrs[0], offset: moveIt ? 120 : -120, backgroundColor: .red) { view in
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    self.moveIt.toggle()
                }
            }
            .animation(animation)

            CommentLabelView(text: commentStrs[1], offset: moveIt ? 120 : -120, backgroundColor: .blue) { view in

                       }
                .animation(animation.delay(0.1))

            CommentLabelView(text: commentStrs[2], offset: moveIt ? 120 : -120, backgroundColor: .green) { view in

                       }
            .animation(animation.delay(0.2))

            CommentLabelView(text: commentStrs[3], offset: moveIt ? 120 : -120, backgroundColor: .yellow) { view in

                       }
                .animation(animation.delay(0.3))

        }
        .onTapGesture { self.moveIt.toggle() }
    }
}

struct CommentLabelView: View {
    let text: String
    var offset: CGFloat
    let backgroundColor: Color
    var completion: (CommentLabelView) -> Void

    var body: some View {

        Text(text)
            .font(.headline)
            .padding(5)
            .background(BackgroundLabelView(cornerRedius: 5, color:backgroundColor))
            .foregroundColor(Color.black)
            .modifier(SlidingEffect(offset: offset, completion: { effect in
                self.completion(self)
            }))
    }
}

struct SlidingEffect: GeometryEffect {
    var offset: CGFloat
    let goalOffset: CGFloat
    var completion: (SlidingEffect) -> Void

    init(offset: CGFloat, completion: @escaping (SlidingEffect) -> Void) {
        self.offset = offset
        self.goalOffset = self.offset
        self.completion = completion
    }

    var animatableData: CGFloat {
        get { return CGFloat(offset) }
        set {
            offset = newValue
            if offset == self.goalOffset {
                self.completion(self)
            }
        }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: offset, ty: 0))
    }
}

struct BackgroundLabelView: View {
    var cornerRedius: CGFloat
    var color: Color

    var body: some View {
        GeometryReader { geometry in
            VStack(spacing:0) {
                RoundedRectangle(cornerRadius: self.cornerRedius).foregroundColor(self.color)
                    .frame(width: geometry.size.width, height: geometry.size.height / 2)
                RoundedRectangle(cornerRadius: self.cornerRedius).foregroundColor(Color.white)
                    .frame(width: geometry.size.width, height: geometry.size.height / 2)
            }
        }
    }
}

struct GECommentView_Previews: PreviewProvider {
    static var previews: some View {
        GECommentView()
    }
}

まず注目してほしいのが、bodyの中の、大枠の構造です。

    var body: some View {
      //...
      return VStack {
        //...
      }
      .onTapGesture { self.moveIt.toggle() }
    }

VStack というのは、V = vertical なstack、つまり垂直に要素を並べるためのstructです。このようなstructはいくつか種類があり、

  • HStack … 水平に要素を並べられる
  • ZStack … overlayで要素を並べられる
  • ScrollView … スクロールさせられる
  • Spacer … スペースを作り出す

などがあります。3

ジェスチャーを定義する

これも簡単です。
先ほどあった以下の部分は、

      .onTapGesture { self.moveIt.toggle() }

該当のviewをタップした際に、カッコ内の処理をさせるという事を示しています。
この例では、viewをタップする度に、moveItプロパティのbool値をtrue/falseとトグルで切り替えます。

アニメーションを定義する

アニメーションもジェスチャーのように簡単に定義できます。
先ほどのコードの以下の部分、

    let animation = Animation.easeInOut(duration: 1.0)

    CommentLabelView(...) { view in
    }
    .animation(animation.delay(0.1))

    CommentLabelView(...) { view in
    }
    .animation(animation.delay(0.2))

このように書くだけで、イージングでアニメーションできます。 4
よく見ると、下のCommentLabelViewには、delay をかけています。
こうする事で、同時にアニメーションを効かせたときに、ちょっとずつアニメーションをずらしていくことが可能です。

他にも、linear や、spring など、様々なアニメーションが用意されています。5

viewの状態を管理する

viewの状態管理には、いくつか種類がありますが、今回は、StateBinding について紹介します。

State - viewの内部でのみ状態を持たせたい場合

viewの内部でのみ状態を持ちたい場合は、 @State プロパティラッパーを使います。

    struct GECommentView: View {
      @State private var moveIt = false
      @State private var commentStrs = {
        //...
      }

    //...
    var body: some View {
      //...
        CommentLabelView(text: commentStrs[0], offset: moveIt ? 120 : -120, backgroundColor: .red) { view in
          //...
        }
    }

途中、省略しまくりなコードで若干わかりにくいですが、注目すべき箇所は、body内で moveIt がCommentLabelViewを生成する際の条件判定に利用されているということです。
このように、@State なプロパティをbody内で参照している場合、そのプロパティ値を変更するたびに、body内のviewが再計算されるという仕組みになっています。
これによって、Viewの状態に合わせてアニメーションさせたり表示を変更することが可能になります。

たとえば、以下のように書くと、

    var body: some View {
      if moveIt {
        HogeView()
      }
    }

moveItがtrueのときだけHogeViewを表示し、falseのときは非表示(viewが存在しない状態)という動きを作ることができます。

Binding - viewの外から状態を指定したい場合

viewの外から状態を指定したい場合は、 @Binding を使います。
今回のサンプルでは、Bindingを使っていないので書き方だけ紹介します。

    struct HogeView: View {
      @Binding var moveIt: Bool = false
      //...

    }

このように定義し、

    HogeView(moveIt: true)

外から状態を渡します。

GeometryReaderで座標やsizeを取得する

最初のほうで少し触れましたが、viewから座標やsizeを取得する方法がありません。
そういうときは、GeometryReaderを利用します。

    struct BackgroundLabelView: View {
      //...
      var body: some View {
        GeometryReader { geometry in
          VStack(spacing:0) {
            RoundedRectangle(cornerRedius: self.cornerRedius).foregroundColor(self.color).frame(width: geometry.size.width, height: geometry.size.height /2)
            RoundedRectangle(cornerRedius: self.cornerRedius).foregroundColor(Color.white).frame(width: geometry.size.width, height: geometry.size.height /2)
          }
      }
    }

このように、GeometryReader のクロージャの引数の geometry (GeometryProxy型) を使うと、sizeやwidth、heightなどが取得できます。
この場合、BackgroundLabelViewを貼り付けたCommentLabelViewのTextのサイズをとることができます。

GeometryEffectで複雑なアニメーションを実現する

シンプルなアニメーションの場合は、.animation()withAnimation { } で十分ですが、複雑なアニメーションを行う場合は、それだけでは難しいです。
そういうときは、GeometryEffect を使うとよいです。

struct CommentLabelView: View {
    //...

    var body: some View {

        Text(text)
            //...
            .modifier(SlidingEffect(offset: offset, completion: { effect in
                self.completion(self)
            }))
    }
}

struct SlidingEffect: GeometryEffect {
    var offset: CGFloat
    let goalOffset: CGFloat
    var completion: (SlidingEffect) -> Void

    init(offset: CGFloat, completion: @escaping (SlidingEffect) -> Void) {
        self.offset = offset
        self.goalOffset = self.offset
        self.completion = completion
    }

    var animatableData: CGFloat {
        get { return CGFloat(offset) }
        set {
            offset = newValue
            if offset == self.goalOffset {
                self.completion(self)
            }
        }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: 0, d: 1, tx: offset, ty: 0))
    }
}

CGAffineTransform を使って、色々とviewの形状を変更することができそうですが、今回はシンプルにviewを左右に移動させることに利用しています。

サンプルコードで工夫したところは、アニメーションによってviewが左右の端まで来たことを親view(この場合、GECommentView)に通知するために、completionのクロージャを利用しているところです。

Macアプリとして実行する

ここまでSwiftUIの基礎を駆け足で紹介してきました。
最後にMacCatalystを利用して、macアプリとして動かしてみましょう。

実行は非常に簡単です。

プロジェクトファイル>General>Deployment Info>Device>「Mac」のチェックをつける

これだけです。 6 7

maccheckbox_screenshot

実行すると、このような表示になります。

iphone_screenshotmac_screenshot

おわりに

やや駆け足でしたが、SwiftUIの使い方の概要を紹介しました。
(Mac用に最適化する部分については全く触れられなかったので、もっと勉強していきたいと思います。)

viewを組み立てるときの概念がこれまでのUIKitとは異なっていて、最初はとっつきにいくい印象を受けますが、慣れればもっと簡単に、複雑なレイアウトをシンプルなコードで実現できるのではないかと思います。
viewまわりのコーディングをGUIを使わずに開発できるようになることは、今後のアプリ開発スタイルにも様々な影響を与えるのではないかなと思います。

今後は、そのあたりも模索してみたいと思っています。形になったらまた記事にしたいと思います。最後まで読んでいただき、ありがとうございました!

明日は、アドベントカレンダーも最終日です。
しかし… おかわり版である 第二のドワンゴ Advent Calendar 2019 の最終枠はまだ空いている模様。果たしていったい誰が書くのか!? いや、書かないのか!?乞うご期待です!

それではみなさま、素敵なクリスマスをお過ごしください🎁

お役立ちリンク

SwiftUI Tutorials
まずは一次情報を。チュートリアルがしっかり用意されているので学習しやすいです。
APIドキュメントはまだ説明が足りていない箇所も見受けられます。

https://developer.apple.com/documentation/swiftui
https://developer.apple.com/tutorials/swiftui/

Mac Catalyst
こちらも一次情報。
しっかりドキュメントがまとまっています(まだあまり読めていませんが…)

https://developer.apple.com/design/human-interface-guidelines/ios/overview/mac-catalyst/
https://developer.apple.com/documentation/uikit/mac_catalyst

The SwiftUI Lab
SwiftUIの細かいところまで具体的なサンプルコードで解説されていてすごく参考になります。

https://swiftui-lab.com
https://swiftui-lab.com/swiftui-animations-part2

Personal-Factory
WWDCのセッションや、SwiftUIの基礎について、詳しくわかりやすく書かれています。
最初に一読したほうがその後の学習が楽になると思います。

https://blog.personal-factory.com/2019/08/20/building-custom-views-with-swiftui/
https://blog.personal-factory.com/2019/12/08/how-to-know-coorginate-space-by-geometryreader/

AVPlayerをSwiftUIで利用する方法
AVPlayerをSwiftUIで利用する際の実装方法の参考になります。
https://medium.com/@chris.mash/avplayer-swiftui-b87af6d0553

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした