SwiftUI でYoutube動画プレイヤーを作ってみる
はじめに
SwiftUIでアプリを作っていても、どうしてもUIKitの資産を使いたい場面が出てくる事が多いですよね。UIKitむけの豊富なライブラリたちを使いたい場合などもたくさんあると思います。
今回はまさに、UIKitむけのYoutubeプレイヤーライブラリである YoutubeKit をSwiftUIのアプリに組み込んでみたいと思います。
とりあえずの完成形
今回はこんな感じのものを作ってみたいと思います。
GitHubはこちらです github.com
開発環境
参考書
SwiftUIの勉強には、この辺りの本が特におすすめです!
YoutubeKitを取り込む
今回はcocoapods
を使って、YoutubeKitを組み込んでいます。Swift Package Manager
を使いたかったのですが、なぜかビルドが通りませんでした...
pod 'YoutubeKit'
UIViewRepresentable
UIViewRepresentable
を使う事で、UIKitベースのViewをSwiftUIで利用可能なViewにラッピングできます。
今回は、YoutubeKitに含まれるYTSwiftyPlayer
を、SwiftUIで利用可能なPlayerView
としてラップします。
import SwiftUI import YoutubeKit struct PlayerView: UIViewRepresentable { typealias UIViewType = YTSwiftyPlayer // 初期化処理を記述. func makeUIView(context: Context) -> YTSwiftyPlayer { // 初期化処理・初期設定. let player = YTSwiftyPlayer() player.autoplay = true return player } // 更新処理を記述. func updateUIView(_ uiView: YTSwiftyPlayer, context: Context) { // 再生する動画の設定. uiView.setPlayerParameters([ .playsInline(true), .videoID("TST0CURe3wM"), ]) uiView.loadPlayer() } }
makeUIView
には、対象のUIViewを生成・初期化するための処理を記述します。今回はYTSwiftyPlayer
のインスタンスの生成を行っています。
updateUIView
には、PlayerViewが更新されるたびに呼び出される処理を記述します。今回は、再生する動画の設定と読み込み処理を行っています。事前に自動再生を有効にしているので、読み込みが終わり次第再生が開始されます。
ContentView
でこのPlayerView
を読み込んで動きを確認してみます。
struct ContentView: View { var body: some View { PlayerView() } }
出来上がりはこんな感じです。画面いっぱいにPlayerView
が広がって、固定で動画が再生されていますね。
プレイヤーの大きさを調整
このままだとプレイヤーの見た目が残念なので、ContentView
に色々と処理を追加し、PlayerView
のサイズを調整してみます。
プレイヤーの幅はデバイス幅に合わせ、高さは16:9になるように算出します。
@State private var playerSize: CGSize = .zero var body: some View { VStack { PlayerView() .frame( // 算出された値でサイズ指定. width: playerSize.width, height: playerSize.height ) } // onAppearでサイズの計算を行う. .onAppear { // windowサイズからplayerサイズを算出. let frame = UIApplication.shared.windows.first?.frame ?? .zero // 縦横比が16:9になるように高さを調整. playerSize = CGSize( width: frame.width, height: frame.width / 16 * 9 ) } }
それらしくなりましたね。
動画の切り替え機能を追加する
今は再生する動画が固定になっていますが、これを切り替えるための機能を追加します。
まずは切り替え対象となる動画の種類をenumVideo
として定義します。
enum Video: String, CaseIterable { case violin case guitar case amaiyume var videoId: String { switch self { case .violin: return "TST0CURe3wM" case .guitar: return "QW2TfV20FXY" case .amaiyume: return "Y-ou8d-wMoI" } } }
ContentView
には、選択された動画を保持する変数と動画切り替えのUIを追加します。
// 選択された動画を保持する変数を追加 @State private var selected: Video = .violin var body: some View { VStack { // ~省略~ // 動画切り替えのUIを追加. Picker("動画選択", selection: $selected) { ForEach(Video.allCases, id: \.self) { video in Text(video.rawValue.uppercased()) } } // picker style 色々. // .pickerStyle(InlinePickerStyle()) .pickerStyle(SegmentedPickerStyle()) // .pickerStyle(MenuPickerStyle()) } .onAppear { // ~省略~ } }
今回はPicker
をSegmentedPickerStyle()
で表示しましたが、iOS14からstyleが追加されているので、それらを試してみるのも面白いかもしれません。
- InlinePickerStyle()
- MenuPickerStyle()
選択された動画を再生する
切り替えのUIは作ったので、実際に動画を切り替える処理を追加します。
PlayerView
には、選択された動画のVideo
を@Binding
の変数として定義し、その値を利用して動画を再生するように修正します。
// 選択されたVideoを受け取る@Binding変数を追加. @Binding var video: Video // ~省略~ func updateUIView(_ uiView: YTSwiftyPlayer, context: Context) { // 再生する動画の設定. uiView.setPlayerParameters([ .playsInline(true), .videoID(video.videoId), // 選択された値のvideoIdを利用する. ]) uiView.loadPlayer() }
ContentView
では、PlayerView
に選択されたVideo
をbindingで渡します。
// コンストラクタに値を追加. PlayerView(video: $selected) .frame( width: playerSize.width, height: playerSize.height )
これで完成です!出来上がりはこんな感じです!
Youtube Data API
YoutubeKitには、他にもYoutube Data API を使うための機能など、他にもさまざまな機能が実装されています。今回は解説しませんが、これらと組み合わせる事でこんな感じのアプリも作れます!
また、今回はシミュレータを利用していますが、実機を利用すれば「ピクチャインピクチャ」を利用することもできるので、ぜひ試してみてください。
まとめ
UIViewRepresentable
を利用することで、UIKitの資産を使ったSwiftUIアプリを作る事ができます。先人たちによって作られた様々なライブラリやツールを使って、SwiftUIのアプリを作っていきましょう!
おまけ
今回使ったYoutube動画はこちらです。
SwiftUI 2.0 で MapKit を使って地図アプリを作ってみる
はじめに
SwiftUI 2.0 では、地図を表示するためのビュー「Map」がMapKitに追加されました。以前は、地図を利用するにはUIViewRepresentable
を使う必要があったのでちょっと面倒だったのですが、これでとても楽になりました!
ということで、今回は SwiftUI 2.0 & MapKit を利用して、地図アプリを作ってみようと思います!
とりあえずの完成形
今回はこんな感じのものを作ってみたいと思います。
【SwiftUI 2.0】MapKit で現在地とAnnotaionを表示する
GitHubはこちらです github.com
ざっくり機能
- 位置情報の取得
- デフォルトではおおよその位置情報を取得する
- 正確な位置情報をリクエストする
- 地図を表示する
- 現在地ボタン
- タップしたら現在地を表示する
- クリアボタン
- タップしたらアノテーションをクリアする
開発環境
参考書
SwiftUIの勉強には、この本が特におすすめです!
位置情報の扱いなどはこちらを参考に
ViewModelを追加
Mapを表示するために必要な情報は、すべてViewModel
で定義します。以下のようなMapViewModel
を作成してみます。
import SwiftUI import MapKit final class MapViewModel: ObservableObject { // 初期表示の座標. @Published var region = MKCoordinateRegion( center: CLLocationCoordinate2D( latitude: 35.6812362, longitude: 139.7671248 ), latitudinalMeters: 10_000, longitudinalMeters: 10_000 ) @Published var trackingMode: MapUserTrackingMode = .follow }
Mapの表示位置を管理するregion
を定義しています。とりあえず東京駅の座標を指定していますが、ここはお好みで問題ありません。
今回はtrackingMode
にはあまり触れていませんが、ユーザの位置情報を追跡するかどうかのBool値です。
とりあえず地図を表示してみる
メインとなるViewをMapView
として定義し、その中で地図を表示してみます。
import SwiftUI import MapKit struct MapView: View { @StateObject private var viewModel = MapViewModel() var body: some View { Map( coordinateRegion: $viewModel.region, interactionModes: .all, showsUserLocation: true, userTrackingMode: $viewModel.trackingMode ) .ignoresSafeArea() } }
Mapkit
をインポートし、Map
を表示します。その際は@StateObject
として先程のMapViewModel
を定義し、そのプロパティをMap
に指定しています。
地図を表示するだけであれば、これだけで完成です。とても簡単ですね!
おおよその現在地を表示するための準備
続いて、おおよその現在地を表示してみます。iOSアプリでは「正確な現在地」と「おおよその現在地」を取得することができますが、今回はデフォルトを「おおよその現在地」とします。
Info.plist
に以下のように項目を追加します。
CLLocationManagerを用意する
位置情報を取得するためのCLLocationManager
を利用するため、MapViewModel
を以下のように改修します。
// NSObject を継承する. final class MapViewModel: NSObject, ObservableObject { // CLLocationManagerを生成して保持. private let manager = CLLocationManager() // ~省略~ // イニシャライザで自身をデリゲートに設定する. override init() { super.init() manager.delegate = self } } // CLLocationManagerDelegateを実装する extension MapViewModel: CLLocationManagerDelegate { // 位置情報関連の権限に変更があったら呼び出される. func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { print(#function) } }
位置情報の権限をリクエスト
続いて、位置情報の利用権限をリクエストするため、MapViewModel
のデリゲートメソッド部分を以下のように改修します。
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { print(#function) if manager.authorizationStatus == .authorizedWhenInUse { print(#function, "権限があるので位置情報をリクエスト.") manager.startUpdatingLocation() } else { print(#function, "権限がないので権限をリクエスト.") manager.requestWhenInUseAuthorization() } }
位置情報の権限を確認し、権限があれば位置情報のリクエスト、なければ権限のリクエストを行っています。
現在地の取得と利用
MapViewModel
に、位置情報を取得した際に呼び出されるデリゲートメソッドを追加します。
// 位置情報が更新されたら呼び出される. func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { print(#function) manager.stopUpdatingLocation() guard let location = locations.first else { return } // 取得した位置情報を reigon.center に与える. withAnimation { region.center = location.coordinate } }
位置情報を取得したら、位置情報のリクエストを一旦停止し、受け取った位置情報をregion.center
に与えています。これで、おおよその位置情報を取得し、地図に反映するようになりました。
正確な位置情報を利用するための準備
正確な位置情報を利用したい場合には、その権限のリクエストを行う必要があります。まずはInfo.plist
に「正確な位置情報を利用する理由」を追加します。
理由は複数設定可能になっているため、key & value で定義します。
正確な位置情報をリクエストする
Info.plist
に理由を追加したら、MapViewModel
のデリゲートメソッド内で、正確な位置情報の利用権限をリクエストします。権限はaccuracyAuthorization
で確認を行うことができます。
リクエストの際には、先程定義した key & value の key の値を指定しています。
if manager.authorizationStatus == .authorizedWhenInUse { print(#function, "権限があるので位置情報をリクエスト.") // 正確な位置情報を利用する権限があるかどうか. if manager.accuracyAuthorization != .fullAccuracy { // 正確な位置情報をリクエスト. manager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "full_accuracy_message") } manager.startUpdatingLocation() } else { // ~省略~
これで、正確な位置情報をリクエストするようになりました。
現在地をリクエストするメソッド
地図を自由に操作した後、表示位置を現在地に戻すための機能を追加します。まずはMapViewModel
に現在地の取得をリクエストするためのメソッドを追加します。
/// 位置情報のリクエスト. func requestUserLocation() { manager.startUpdatingLocation() }
これで位置情報を取得すれば、既に実装しているlocationManager(_ : didUpdateLocations:)
が呼び出されます。
現在地ボタンを追加する
では、このメソッドを呼び出すためのボタンを画面上に追加します。
var body: some View { ZStack(alignment: Alignment(horizontal: .trailing, vertical: .bottom)) { Map( ... ).ignoresSafeArea() // ~省略~ // ボタン領域. HStack(spacing: 24.0) { // 位置情報リクエストボタン. Button(action: { viewModel.requestUserLocation() }, label: { Image(systemName: "dot.circle.and.cursorarrow") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 44.0) }) } .padding(24.0) } }
全体をZStack
で囲い、前面の右下にボタン領域を表示しています。ボタンをタップしたら、先程用意したメソッドを呼び出すようにしています。
これで、ボタンをタップすれば現在地が中央に表示されるようになりました。
位置情報を管理する構造体を追加
位置情報を保持するための構造体を追加します。Identifiable
に準拠させます。(初期値を適当に与えていますが、初期化を楽にするためです)
import SwiftUI import MapKit struct MapItem: Identifiable { var id = UUID().uuidString var coordinate = CLLocationCoordinate2D() var color = Color.red }
画面の表示位置を記録していく
地図の表示位置が変化するたびに、その位置情報を取得し、MapItem
の配列として保持していきます。
import Combine // 追加 final class MapViewModel: NSObject, ObservableObject {
まずはMapViewModel
に非同期処理を行うためのimport Combine
を追加します。
// 以下のプロパティを追加 @Published var items: [MapItem] = [] private var cancellable = Set<AnyCancellable>() override init() { super.init() manager.delegate = self // 位置情報の変化を監視する $region .debounce(for: 0.5, scheduler: DispatchQueue.main) .sink { [weak self] region in // 位置情報が変化したら、その位置情報からMapItemを生成して保持する. let annotation = MapItem(coordinate: region.center, color: .blue) self?.items.append(annotation) } .store(in: &cancellable) }
MapViewModel
のイニシャライザに、region
の変化を監視する処理を追加します。region
が変化したら、その座標情報を取得し、MapItem
を生成しています。生成したMapItem
は配列として保存していきます。
変化した値を全て取得すると大変なことになるので、debounce
を利用して0.5秒間のインターバルを設けています。
取得した位置情報をポインティング
Map
のイニシャライザを修正し、地図上にポインティングできるようにしています。ここではまずMapMaker
を利用しています。
Map( coordinateRegion: $viewModel.region, interactionModes: .all, showsUserLocation: true, userTrackingMode: $viewModel.trackingMode, annotationItems: viewModel.items ) { item in MapMarker(coordinate: item.coordinate, tint: item.color) } .ignoresSafeArea()
これで、地図を移動するたびにマーカーが表示されるようになりましたね!
任意のマーカービューを作成
MapMaker
は初めから用意されているマーカーですが、任意のViewを利用することも可能です。まずはマーカー用のViewを作成してみます。
import SwiftUI struct MapAnnotationView: View { var item: MapItem var body: some View { Image(systemName: "bubble.middle.bottom.fill") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 44.0) .foregroundColor(item.color) .shadow(color: Color.black.opacity(0.5), radius: 5, x: 5, y: 5) } }
特になんの変哲もない普通のViewです。MapItem
を受け取るようにしています。
任意のViewをマップ上に表示する
では、地図上にMapMaker
の代わりに独自のMapAnnotationView
を配置してみます。
Map( // ~省略~ ) { item in MapAnnotation( coordinate: item.coordinate, anchorPoint: CGPoint(x: 0.5, y: 1.0) ) { MapAnnotationView(item: item) } } .ignoresSafeArea()
MapMaker
の代わりにMapAnnotation
を指定しています。位置情報はこのイニシャライザに渡します。(anchorPoint
は、ビューのどのポイントを位置情報に合わせるか、を指定するものです。)表示したいビューは、このクロージャに指定します。
これで、表示されるマーカーが独自のViewになりました。
マーカーの削除
MapViewModel
にitems
を初期化するメソッドを追加し、画面上にそのメソッドを呼び出すボタンを配置します。
/// MapItemをクリア. func removeItems() { items.removeAll() }
MapView
にはボタンを追加し、上記のメソッドを呼び出します。
HStack(spacing: 24.0) { // MapItemのクリアボタン. Button(action: { viewModel.removeItems() }, label: { Image(systemName: "mappin.slash") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 44.0, height: 44.0) }) // 位置情報リクエストボタン. // ~省略~ } .padding(24.0)
これで画面上に表示されているマーカーを削除することができます。
詳細画面のビューを追加
マーカータップ時に表示される詳細画面のビューを作成します。背景を半透明のビューにし、その中央にマーカーと同じ画像を少し大きめに表示します。matchedGeometryEffect
を利用することで、heroアニメーションを実現しています。
import SwiftUI import MapKit struct DetailView: View { @Binding var mapItem: MapItem @Binding var showModal: Bool var namespace: Namespace.ID var body: some View { ZStack { // 背景色 Color.black.opacity(0.2).ignoresSafeArea() // MapItemの画像. Image(systemName: "bubble.middle.bottom.fill") .resizable() .aspectRatio(contentMode: .fit) .matchedGeometryEffect(id: mapItem.id, in: namespace) .frame(width: UIScreen.main.bounds.width - 100.0) .foregroundColor(mapItem.color) .shadow(color: Color.black.opacity(0.5), radius: 5, x: 5, y: 5) } // タップしたら閉じる. .onTapGesture { withAnimation { showModal.toggle() mapItem = MapItem() } } } }
MapAnnotationViewにNamespaceを渡す
MapAnnotationView
にもNamespace
を渡せるように修正します。アニメーションのIDには、MapItemが持つid
を利用しています。
struct MapAnnotationView: View { var item: MapItem var namespace: Namespace.ID // プロパティを追加する var body: some View { Image(systemName: "bubble.middle.bottom.fill") .resizable() .aspectRatio(contentMode: .fit) // アニメーションのために追加する. .matchedGeometryEffect(id: item.id, in: namespace) .frame(width: 44.0) .foregroundColor(item.color) .shadow(color: Color.black.opacity(0.5), radius: 5, x: 5, y: 5) } }
MapView
でMapAnnotationView
を呼び出している箇所にも修正が必要です。まずはMapView
にもNamespace
を定義します。
@StateObject private var viewModel = MapViewModel() @Namespace var namespace // 追加
MapAnnotationView
のイニシャライザにnamespace
を渡します。
MapAnnotation( coordinate: item.coordinate, anchorPoint: CGPoint(x: 0.5, y: 1.0) ) { MapAnnotationView(item: item, namespace: namespace) // ここを修正 }
マーカーをタップ可能にする
地図上のマーカーをタップ可能にし、タップされたマーカーの情報を保持するように修正します。MapView
に、以下のようにプロパティを追加します。
@State private var showModal = false @State private var selectedItem = MapItem()
また、マーカーにonTapGesture
を追加し、タップされたマーカー情報を保持します。
MapAnnotationView(item: item, namespace: namespace) .onTapGesture { withAnimation { showModal.toggle() // フラグ反転 selectedItem = item // 選択されたマーカーを保持. } }
詳細ビューを表示する
フラグshowModal
の値を判断し、詳細ビューを表示します。
ZStack(alignment: Alignment(horizontal: .trailing, vertical: .bottom)) { Map(...) // ~省略~ .ignoresSafeArea() HStack(spacing: 24.0) { // ~省略~ } .padding(24.0) // アノテーションが選択されたら、詳細画面を前面に表示する. if showModal { DetailView( mapItem: $selectedItem, showModal: $showModal, namespace: namespace ) } }
これで、タップされたアイテムが詳細ビューとして表示されるようになりました!
まとめ
SwiftUI 2.0 で用意されたMap
は、MKMapView
とはちょっと使い方は異なりますが、位置情報の取得などの階層についてはこれまで通りの処理となります。
地図の表示のやり方、マーカーの使い方など、こちらを参考にやってみてください!
SwiftUI のPathとカスタムシェイプで独自のタブバーを作ってみる
カスタムシェイプとアニメーション
SwiftUIでは、Circle()
やCapsule()
のように初めから用意されているシェイプの他にも、Shape
プロトコルを実装して独自にシェイプを作成することができます。
さらには、そのシェイプにアニメーションを与えることもできるので、早速試してみたいと思います。
とりあえずの完成形
今回はこんな感じのものを作ってみたいと思います。
GitHubはこちらです github.com
開発環境
参考書
SwiftUIの勉強には、この辺りの本が特におすすめです!
CoreGraphicsの勉強は、この辺りがよろしいかと思います。
タブ部分の作成
まずはタブの要素を表すenumを定義しています。
enum TabItem: String, CaseIterable { case home case favorite case settings var imageName: String { switch self { case .home: return "house" case .favorite: return "suit.heart" case .settings: return "gearshape" } } }
このenumを使って、タブ部分の見た目を作成します。
VStack { // tab view. HStack { Spacer() ForEach(TabItem.allCases, id: \.self) { tabItem in Button(action: {}, label: { Text(tabItem.rawValue.uppercased()) .fontWeight(.heavy) .foregroundColor(.white) }) .frame(width: 100, height: 100) } Spacer() } .frame(height: 70) .background( Color.accentColor .ignoresSafeArea() ) // TODO: content body. Spacer() }
出来上がりはこんな感じです。タブをタップしても何も起きませんね。
タブの位置を取得する
タップされたタブの位置情報を取得するための処理を追加します。まずはその位置情報を保持するためのプロパティを@State
で定義します。
// 選択中のタブのx軸中央. @State private var tabMidX: CGFloat = 0
次に、ボタンそれぞれをGeometryRender
で囲い、位置情報などを取得可能にします。
ForEach(TabItem.allCases, id: \.self) { tabItem in // 1. GeometryReaderでボタンを囲う. GeometryReader { geometry in Button(action: { // 2. タップされたボタンのx軸中央を保持. tabMidX = geometry.frame(in: .global).midX }, label: { Text(tabItem.rawValue.uppercased()) .fontWeight(.heavy) .foregroundColor(.white) }) // 3. frameはGeometryReaderに従う. .frame( width: geometry.size.width, height: geometry.size.height ) .onAppear { // 4. 初期表示時のみ、一番左のタブのポジションを保持. if tabItem == TabItem.allCases.first { tabMidX = geometry.frame(in: .global).midX } } } // 5. frameはGeometryReaderで定義. .frame(width: 100, height: 100) }
GeometryRender
を利用して、ボタンを囲っています- ボタンがタップされたら、そのボタンのX軸方向の中心座標を保持します
frame
には直接数値を与えるのではなく、親ビューの値を利用するようにしています- 画面表示のタイミングで、一番左のタブの座標情報を初期値として保持しています
GeometryRender
にframe
情報を与えています
保持している値を確認できるように、デバッグ用のTextを追加しています
// ~ 省略 ~ // TODO: content body. Spacer() // debug. Text("\(tabMidX)") }
できあがりはこんな感じです。
選択されたタブのポジションが取得できているようですね!
簡単なカスタムシェイプを作ってみる
ではカスタムシェイプを作ってタブ部分に適用してみます。
まずは既存のシェイプを返すだけのカスタムシェイプを作ってみます。カスタムシェイプは、Shape
を実装した構造体を作成し、path
というメソッドを実装します。
struct TabShape: Shape { func path(in rect: CGRect) -> Path { // とりあえずCapsuleを返すだけ. Capsule().path(in: rect) } }
.background(
Color.accentColor
// 作成したシェイプでclipShapeを行う.
.clipShape(TabShape())
.ignoresSafeArea()
)
出来上がりはこんな感じです。見事にCapsuleでクリップされましたね。
独自のカスタムシェイプを作ってみる
では改めてカスタムシェイプを作成していきます。まずは基本となる矩形を描画します。
func path(in rect: CGRect) -> Path { Path { path in // 基本となる矩形. path.move(to: .zero) path.addLine(to: CGPoint(x: 0, y: rect.height)) path.addLine(to: CGPoint(x: rect.width, y: rect.height)) path.addLine(to: CGPoint(x: rect.width, y: 0)) path.closeSubpath() } }
次に、カスタムシェイプにタブの位置情報のためのプロパティを定義します。
struct TabShape: Shape { // タブのX軸方向の中心座標. var tabMidX: CGFloat // ~ 省略 ~ }
呼び出し側にも修正を加えます。
.background( Color.accentColor // TabShapeの引数にtabMidXを指定. .clipShape(TabShape(tabMidX: tabMidX)) .ignoresSafeArea() )
曲線を描画する
曲線のサイズやそれぞれのコントロールポイントのポジションがどのように算出されているかは、下図を参考にしてください。
では実際に曲線を描画します。
Path { path in // ~省略~ let curveWidth = rect.width / 10 let curveHeight = rect.height / 5 // 始点. path.move(to: CGPoint(x: tabMidX - curveWidth, y: rect.height)) // 左半分. let to1 = CGPoint(x: tabMidX, y: rect.height - curveHeight) let ctrl1a = CGPoint(x: tabMidX - (curveWidth / 2), y: rect.height) let ctrl1b = CGPoint(x: tabMidX - (curveWidth / 2), y: rect.height - curveHeight) path.addCurve(to: to1, control1: ctrl1a, control2: ctrl1b) // 右半分. let to2 = CGPoint(x: tabMidX + curveWidth, y: rect.height) let ctrl2a = CGPoint(x: tabMidX + (curveWidth / 2), y: rect.height - curveHeight) let ctrl2b = CGPoint(x: tabMidX + (curveWidth / 2), y: rect.height) path.addCurve(to: to2, control1: ctrl2a, control2: ctrl2b) }
出来上がりはこんな感じです。選択されたボタンに合わせて、カスタムシェイプが描画されていますね。
カスタムシェイプにアニメーションを与える
カスタムシェイプにアニメーションを与えるには、カスタムシェイプ内にanimatableData
を定義する必要があります。アニメーションを伴って変化させたいプロパティを、以下のように記述します。
struct TabShape: Shape { var tabMidX: CGFloat // アニメーションを伴って変化させたい値を、getter / setter で定義する var animatableData: CGFloat { get { tabMidX } set { tabMidX = newValue } } // ~省略~
ボタンタップ時の処理は、withAnimation
を使って囲います。今回はボヨヨン感を出すため、interactiveSpring
を利用しています。
Button(action: { // アニメーションを伴って変化させる. withAnimation( .interactiveSpring( response: 0.5, dampingFraction: 0.5, blendDuration: 0.5 ) ) { // タップされたボタンのx軸中央を保持. tabMidX = geometry.frame(in: .global).midX } }, label: { // ~省略~
出来上がりはこんな感じです。アニメーションを伴って変化するようになりましたね!
それぞれのタブにアイコンを加える
では、選択中のタブにアイコンを表示してみます。まずは選択中のタブを保持するためのプロパティを@State
で定義します。
// 選択中のタブ. @State private var selected: TabItem = .home
それぞれのタブボタンに、アイコン画像を与えます。ZState
を利用して重ねるようなイメージで配置します。
ForEach(TabItem.allCases, id: \.self) { tabItem in ZStack { // アイコン画像を追加. Image(systemName: tabItem.imageName) .foregroundColor(.accentColor) // 選択状態で、Y軸方向のポジションを切り替える. .offset(y: tabItem == selected ? 26 : -100) GeometryReader { geometry in // ~省略~ } }
出来上がりはこんな感じです。選択されたタブに合わせて、アイコン画像がアニメーションを伴って現れるようになりました!
コンテンツ部分のViewを切り替える
では、タブビューらしくメイン領域を切り替えられるように実装します。メイン領域にはダミーのビューを配置します。
// コンテンツ領域のダミービューを定義する. struct ContentBodyView: View { let tabItem: TabItem var body: some View { Text(tabItem.rawValue.uppercased()) .font(.largeTitle) .fontWeight(.heavy) .foregroundColor(.accentColor) } }
TabView
を利用してメイン領域にビューを配置します。アイコンが裏に回ってしまわないよう、zIndex
を利用してZ軸方向の順番を調整しています。
// ~省略~ } .frame(height: 70) .background( Color.accentColor .clipShape(TabShape(tabMidX: tabMidX)) .ignoresSafeArea() ) .zIndex(1) // Z軸的に手前に描画する. // TabViewを利用して、メイン領域にビューを配置する. TabView(selection: $selected) { ForEach(TabItem.allCases, id: \.self) { tabItem in ContentBodyView(tabItem: tabItem) .tag(tabItem) } } .zIndex(0) // Z軸的に奥に描画する.
タブビューは不要なので、イニシャライザを利用して非表示にしています。
// ~省略~ // 選択中のタブ. @State private var selected: TabItem = .home init() { UITabBar.appearance().isHidden = true } // ~省略~
ステータスバーやドロップシャドウの調整
Info.plist
のView controller-based status bar appearance
を調整して、ステータスバーの色をlightにしています。
また、タブビューにドロップシャドウを追加しています。
// ~省略~ .background( Color.accentColor .clipShape(TabShape(tabMidX: tabMidX)) .ignoresSafeArea() // ドロップシャドウを追加. .shadow( color: Color.black.opacity(0.1), radius: 1, x: 0, y: 5 ) ) .zIndex(1)
これで完成です!出来上がりはこんな感じです!
まとめ
カスタムシェイプを使えば、独自の計上の背景などをたやすく作ることができます!アニメーションを与えることもできますので、色々と試してみてください。
今回作ったアプリのGitHubはこちらです。よかったら参考にしてみてください! github.com
SwiftUI 2.0 でカスタムタブビューを作ってみた
SwiftUI 2.0で追加されたAPI
SwiftUI 2.0 で追加されたPageTabViewStyle
を使ってみたかったので、カスタムタブのようなものを作ってみました。
とりあえずの完成形
こういったものを作っていこうと思います。
GitHubはこちらです。 github.com
開発環境
参考書
SwiftUIの勉強には、この辺りの本が特におすすめです!
事前準備
- アニメーションgifを利用したかったのでSDWebImageSwiftUIを利用しています。
- ぴよたそさんからアニメーションgifファイルを利用させてもらっています。
タブ部分の作成
アニメーションgifファイルはこのように名前をつけて配置したので、名前を合わせる形でenumを定義しています。
enum TabItem: String, CaseIterable { case piyo case pen case neko case tobipen var name: String { "\(self.rawValue).gif" } }
タブの一つ一つを表すためのTabItemView
を追加して、以下のように定義しました。
struct TabItemView: View { let tabItem: TabItem @Binding var selected: TabItem var body: some View { // SDWebImageSwiftUIのimportが必要. AnimatedImage(name: tabItem.name) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 40) .onTapGesture { selected = tabItem // タップしたら自身をselectedに. } } }
メインとなるContentView
には、以下のようにタブビューを定義しました。
struct ContentView: View { // タブの選択値と初期値. @State private var selected: TabItem = .piyo var body: some View { // タブビュー部分. HStack { ForEach(TabItem.allCases, id: \.self) { tabItem in TabItemView(tabItem: tabItem, selected: $selected) } } .padding(.vertical, 10.0) .padding(.horizontal, 20.0) .background(Color.white.clipShape(Capsule())) .shadow(color: Color.black.opacity(0.3), radius: 5, x: -5, y: 5) } }
出来上がったのはこちらです。このままだと、選択状態がよくわからないですね。
選択状態がわかるように見た目を調整する
選択時/非選択時で見た目を切り替えるため、TabItemView
を以下のように書き換えます。
- frameを調整
- paddingを調整
- offsetを調整
- タップ時の処理にアニメーションを伴わせる
var body: some View { AnimatedImage(name: tabItem.name) .resizable() .aspectRatio(contentMode: .fit) // 選択状態によって、サイズや間隔を調整する. .frame(width: tabItem == selected ? 100 : 40) .padding(.vertical, tabItem == selected ? -30 : 0) .padding(.horizontal, tabItem == selected ? -14 : 16) .offset(y: tabItem == selected ? -15 : 0) .onTapGesture { withAnimation(.spring()) { selected = tabItem // タップしたら自身をselectedに. } } }
見た目はこのようになります。選択状態が一目でわかるようになりました。
背景色の設定と、タブの配置調整
ContentView
の見た目を調整します。
- 全体を
ZStack
で囲う - 最背面に
Color("bg").ignoresSafeArea()
を配置して背景色とする - タブビュー部分を
VStack
とSpacer
を利用して画面下部に配置
var body: some View { ZStack { // 背景色. Color("bg").ignoresSafeArea() VStack { Spacer(minLength: 0) // タブビュー部分. HStack { ForEach(TabItem.allCases, id: \.self) { tabItem in TabItemView(tabItem: tabItem, selected: $selected) } } .padding(.vertical, 10.0) .padding(.horizontal, 20.0) .background(Color.white.clipShape(Capsule())) .shadow(color: Color.black.opacity(0.3), radius: 5, x: -5, y: 5) } } }
見た目はこうなりました。それっぽくなってきましたね。
画面をタブで切り替える
タブは用意したので、このタブに連動して画面が切り替わるようにします。
まずはダミーで画面部分を用意します。
適当なので、こちらは好きに作ってもらって良いと思います。
struct HomeView: View { var body: some View { Text("Home") .font(.largeTitle) .fontWeight(.heavy) .foregroundColor(.red) } } struct ListView: View { var body: some View { Text("List") .font(.largeTitle) .fontWeight(.heavy) .foregroundColor(.green) } } struct SearchView: View { var body: some View { Text("Search") .font(.largeTitle) .fontWeight(.heavy) .foregroundColor(.blue) } } struct SettingView: View { var body: some View { Text("Setting") .font(.largeTitle) .fontWeight(.heavy) .foregroundColor(.yellow) } }
最後に、これらのViewをTabView
で定義し、カスタムタブと連動するようにします。
TabView
の引数にselected
を指定することで、カスタムタブと連動させるPageTabViewStyle
を指定することで、横スワイプでの切り替えを可能にする
ZStack { // 背景色. Color("bg").ignoresSafeArea() // メイン画面部分はTabViewで定義. TabView(selection: $selected) { HomeView() .tag(TabItem.piyo) ListView() .tag(TabItem.pen) SearchView() .tag(TabItem.neko) SettingView() .tag(TabItem.tobipen) } // PageTabスタイルを利用する(インジケータは非表示). .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) VStack { // 省略. } }
まとめ
タブの定義がずいぶん簡単にできる印象ですが、それ以上に「切り替え用のUI」を簡単に作成できるのは嬉しいですね。
こちらの記事で作った切替ビューでも同じようなことができそうです。もしよかったら試してみてください。 hoshi0523.hatenablog.com
SwiftUI 2.0で matchedGeometryEffect を使ってみる
SwiftUI 2.0で追加されたAPI
SwiftUI 2.0で追加された新機能のうち、Heroアニメーションを簡単に作れるAPIがあったのでちょっと触ってみました。
とりあえず完成形
こんな感じのSegmentControlっぽいUIを作ってみます。
Githubはこちらです。 github.com
開発環境
ボタンを作る
選択に使うボタンのViewを作ります。 そのまえに、適当にenumを定義しておきました。SF Symbolsから、適当に4つほど選出しています。
enum ButtonType: String, CaseIterable { case share = "square.and.arrow.up" case trash = "trash" case folder = "folder" case person = "person" }
ボタンのビューはこんな感じで作ります。
AccentColorについては、適宜Assetsで好きな色を定義してください。
struct CustomButton: View { // 選択状態を表すプロパティ. @Binding var selected: ButtonType // 自分自身のボタンタイプ. let type: ButtonType var body: some View { ZStack { // 選択中だったら背景に円を描画する. if selected == type { Circle() // AccentColorはAssetsで定義すること. .fill(Color.accentColor) } Button(action: { // ボタンをタップしたら選択状態を切り替える. selected = type }, label: { // enumから画像を表示する. Image(systemName: type.rawValue) .resizable() .renderingMode(.original) .aspectRatio(contentMode: .fit) .frame(width: 44, height: 44) }) } .frame(width: 80, height: 80) } }
画面上にボタンを並べる
では、画面上にボタンを並べてみます
struct ContentView: View { @State private var selected = ButtonType.share // 選択状態の初期値. var body: some View { HStack { // enumをforeachで回して、CustomButtonを横に並べる. ForEach(ButtonType.allCases, id: \.self) { type in CustomButton(selected: $selected, type: type) } } } }
では動かしてみます。
選択状態を切り替えて、見た目も変わるようになりましたね。では、ここからアニメーションを追加していきましょう。
アニメーションさせる
まずはボタンの選択時の状態変化がアニメーションを伴うように、ボタンタップ時の挙動を一部修正します。
// 一部抜粋. Button(action: { // アニメーションを伴わせる withAnimation { selected = type } }, label: { // 省略 })
.matchedGeometryEffectを設定する
アニメーションさせたいViewに対して.matchedGeometryEffect
を指定します。
これは、識別子とNamespaceを与えて、同期したいアニメーションをグルーピングする感じです。
まずはnamespaceを宣言します。
struct CustomButton: View { // 省略 var namespace: Namespace.ID // namespaceを追加する. // 省略 }
次に、アニメーションさせたい背景ビューに対して.matchedGeometryEffect
を指定します。識別子は、アニメーションを同期したいグループ間で一致していれば何でもいいです。
// 選択中だったら背景に円を描画する. if selected == type { Circle() .fill(Color.accentColor) .matchedGeometryEffect(id: "CustomButton", in: namespace) }
次に、呼び出しているView側に修正を加えます
struct ContentView: View { @State private var selected = ButtonType.share // 追加する. @Namespace var namespace var body: some View { HStack { ForEach(ButtonType.allCases, id: \.self) { type in // 引数にnamespaceを与えるように修正する. CustomButton( selected: $selected, type: type, namespace: namespace ) } } } }
以上で完成です! とても簡単にできちゃいますね!
まとめ
Heroアニメーションはテンション上がるので、他にも色々と試して記事にしたいと思います!
こちらの書籍はとても参考になったのでおすすめです!
やれたかも委員会 をリリースしました!
新作アプリを公開しました
みなさま、いつもご利用頂きありがとうございます。
Susumu Hoshikawaでございます。
今回は、新作マンガアプリ やれたかも委員会 のリリースご報告です!
ドラマでも絶賛放送中!
現在、TBSにて、ドラマでも絶賛放送中の やれたかも委員会 を、この度マンガアプリとしてリリースさせていただきました!
やれたかも委員会って?
すでにTVでもご覧になった方もいらっしゃると思いますが、吉田貴司さん原作のマンガで、現在はnoteやマンガ on ウェブなどで連載中の作品です。
毎回、女性との「やれたかもしれない」(セックスできたかもしれない)思い出を持つ男性が、面接会場のような場所を訪れ、女性と甘酸っぱさやほろ苦さ残る思い出を独白形式で語る。聞き手である男性2名、女性1名で構成される「やれたかも委員会」メンバーが、最後に「やれた」か「やれたとは言えない」かを判定する。(wikipediaより抜粋)
第1巻は完全に無料で!
現在は2巻まで刊行中ですが、第1巻は完全無料となっております。第2巻の価格は¥240となっております。
ぜひこの機会にお試しください!
【ついに完結】特攻の島 最終巻 第9巻を公開しました
みなさま、いつも当アプリをご利用頂きまして、ありがとうございます。Susumu Hoshikawaでございます。
今回は、拙作アプリ無料で1巻!特攻の島に関するご報告です。
特攻の島 最終巻 ついに完結
佐藤秀峰さんが手がける漫画特攻の島が、長年の連載を経て、ついに完結いたしました。
そして、その最終巻である第9巻が、iPhone/iPadアプリ特攻の島にて、本日より配信を開始いたしました!
漫画「特攻の島」は、特攻兵機「回天」の搭乗員の姿を描いた物語で、当然舞台は太平洋戦争真っ只中。最後の作戦に挑む渡辺の運命は!?是非ともご覧下さい!
最終巻の表紙はこんな感じ!
最終巻の表紙はこのようになっています。
第1巻では、表情に幼さも残していた渡邊でしたが、物語をしめくくるこの最終巻で、何かを悟るような表情を見せています。どのような形で物語が完結するのかは、ぜひ皆様自身の目でお確かめください。
価格は¥240!
第2〜8巻と同様、価格は¥240となっております。書籍版と比べると、なんと半額以下です!
場所もとらずに安くあがる、いつでもどこでも読めるiPhone/iPadアプリ版の特攻の島、どうぞお楽しみください!
以上、今後とも当アプリをよろしくお願いいたします!