メリークリスマス(イヴ)!!🎅🎄
この記事は、第二のドワンゴ Advent Calendar 2019 の24日目の記事です。
そして、今日は12/24、クリスマスイヴです。みなさん、いかがお過ごしでしょうか。
この記事で伝えたい事
この記事は、以下についてまとめています。
-
SwiftUI
を使って、動画プレーヤー上にコメントを表示、アニメーションさせる方法 -
Mac Catalyst
で、 iOS/iPadOSアプリをMacアプリとして動作させる方法(ここはちょっとだけです💦)
対象読者は、iOS開発の経験があり、SwiftUIをこれから触っていこうとしている人です。
自己紹介
お仕事では、iOSアプリ開発を担当しています。
現在の私の役割としては、プロジェクトの進行管理や、調整ごとがメインなため、どうしても自分で手を動かすことが減ってしまっています。
しかし、その状況に甘える事なく、継続して新しい技術に触れ、技術的な視野・知識を広げていくことがエンジニアとして必要不可欠だと感じています。
というわけで、今回、エンジニアとしてのお勉強の一環として、最近学習したSwiftUIの基本事項をまとめるという形で、アドベントカレンダーに参加させていただこうと思います。よろしくお願いします 🙇🏻♂️
ちなみに…
自己紹介ついでに、これまで社内LTで発表したものやAppStoreに公開したものについて触れておくと、
顔認識を使って降り注ぐ自分の顔を避けながら進む、ほうれいせん予防の弾幕ゲームアプリ
とか、きれいなジャイアンからヒントを得たARKitを使ってイケメンになれるアプリ
とか、とてもここでは説明できないようなちょっとおかしなアプリばかりです…
が、社内のエンジニアの皆さんはそんなふざけたテーマでも暖かく受け止めてくれていましたw
…… 大丈夫です。今回は真面目なテーマで書きますので 💦
2019年のiOS開発トピックス
さて、iOSの今年のトピックスといえば、なんといっても、
WWDC2019で Mac Catalyst
と SwiftUI
が発表されたことです。
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でコメントに見立てたテキストを並べて動かすというものを作ってみたいと思います。
映像は、サンタが機械(MACHINE)に溶け込み、光速に飛び込んでいく様子を撮影したものです。
重ねて表示されるテキストは、お寿司です🍣
ハマチがちょっと痛んでしまっています。
SwiftUIの基礎を把握する
UI部分のサンプルコードは次の通りです。
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が決めることになっているようです。
では、ここから PlayerVCView
と GCCommentView
の中を見ていきたいと思います。
UIViewControllerをSwiftUIから呼び出す方法
一般的な SwiftUI
のチュートリアルでは、もっと後のほうに出てくる内容ですが、UIKitを触った事がある人だったら、これを先に見たほうが、 SwiftUI
の理解が早まるかなと思うので、先に説明してしまいます。
PlayerVCView
(自作struct)は動画を再生してくれるプレーヤーのviewです。iOSで動画再生する場合、 AVKit
の AVPlayerViewController
を使うのが簡単です。シークバーなどのコントローラもよしなに提供してくれます。
もうちょっと複雑な制御をしたい場合、プレーヤーを作り込みたい場合は、 AVPlayer
を利用します。
今回は、サンプルプログラムということで、AVPlayerViewController
を使いたいと思います。コードは以下のとおりです。
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
になります。
- 対象のViewControllerクラスの型を指定します。今回の例では、
-
makeUIViewController(context: Self.Context) -> Self.UIViewControllerType
- ViewControllerでいうところの
viewDidLoad
にあたるメソッドです。ViewControllerの初期化などはここで行います。
- ViewControllerでいうところの
-
updateUIViewController(Self.UIViewControllerType, context: Self.Context)
-
viewDidAppear
的なもの? ViewControllerの設定変更などの処理はここで行う。ちなみに、特別な処理がない場合は、空っぽでも問題ありません。
-
あとは、前述のように、このstructのイニシャライザを、表示したいところで呼び出すだけです。
PlayerVCView()
UIViewをSwiftUIから呼び出す方法
UIViewを呼び出す方法も紹介しておきましょう。
AVPlayerを利用するケースだと、以下のようなコードになります。
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です。
実装は次のとおりです。
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の状態管理には、いくつか種類がありますが、今回は、State
と Binding
について紹介します。
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」のチェックをつける
実行すると、このような表示になります。
おわりに
やや駆け足でしたが、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
-
Introducing iPad Apps for Mac https://developer.apple.com/wwdc19/205 ↩
-
niconico http://www.nicovideo.jp/ ↩
-
View Layout and Presentation https://developer.apple.com/documentation/swiftui/view_layout_and_presentation ↩
-
イージングの基本 https://developers.google.com/web/fundamentals/design-and-ux/animations/the-basics-of-easing?hl=ja ↩
-
Drawing and Animation https://developer.apple.com/documentation/swiftui/drawing_and_animation ↩
-
Mac Catalyst (Human Interface Guidelines) https://developer.apple.com/design/human-interface-guidelines/ios/overview/mac-catalyst/ ↩
-
Mac Catalyst (Documentation) https://developer.apple.com/documentation/uikit/mac_catalyst ↩