SH Lab の アプリ開発部屋

リリースしたアプリの告知とかサポートとか、技術的なお話とか、そんな感じでつぶやきます。

SwiftUI でYoutube動画プレイヤーを作ってみる

はじめに

SwiftUIでアプリを作っていても、どうしてもUIKitの資産を使いたい場面が出てくる事が多いですよね。UIKitむけの豊富なライブラリたちを使いたい場合などもたくさんあると思います。

f:id:hoshi0523:20201209224947p:plain

今回はまさに、UIKitむけのYoutubeプレイヤーライブラリである YoutubeKit をSwiftUIのアプリに組み込んでみたいと思います。

github.com

とりあえずの完成形

今回はこんな感じのものを作ってみたいと思います。

f:id:hoshi0523:20201209215547g:plain

GitHubはこちらです github.com

開発環境

参考書

SwiftUIの勉強には、この辺りの本が特におすすめです!

基礎から学ぶ SwiftUI

基礎から学ぶ SwiftUI

  • 作者:林晃
  • シーアンドアール研究所
Amazon

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が広がって、固定で動画が再生されていますね。

f:id:hoshi0523:20201209220211g:plain

プレイヤーの大きさを調整

このままだとプレイヤーの見た目が残念なので、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
        )
    }
}

それらしくなりましたね。

f:id:hoshi0523:20201209220736g:plain

動画の切り替え機能を追加する

今は再生する動画が固定になっていますが、これを切り替えるための機能を追加します。

まずは切り替え対象となる動画の種類を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 {
        // ~省略~
    }
}

今回はPickerSegmentedPickerStyle()で表示しましたが、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
    )

これで完成です!出来上がりはこんな感じです!

f:id:hoshi0523:20201209215547g:plain

Youtube Data API

YoutubeKitには、他にもYoutube Data API を使うための機能など、他にもさまざまな機能が実装されています。今回は解説しませんが、これらと組み合わせる事でこんな感じのアプリも作れます!

また、今回はシミュレータを利用していますが、実機を利用すれば「ピクチャインピクチャ」を利用することもできるので、ぜひ試してみてください。

f:id:hoshi0523:20201209222031g:plain

まとめ

UIViewRepresentableを利用することで、UIKitの資産を使ったSwiftUIアプリを作る事ができます。先人たちによって作られた様々なライブラリやツールを使って、SwiftUIのアプリを作っていきましょう!

おまけ

今回使ったYoutube動画はこちらです。


夜に駆ける / YOASOBI【ヴァイオリンで歌ってみた】


A Metal Tribute to iPhone Ringtones || ToxicxEternity


上田麗奈「あまい夢」 MUSIC VIDEO

SwiftUI 2.0 で MapKit を使って地図アプリを作ってみる

はじめに

SwiftUI 2.0 では、地図を表示するためのビュー「Map」がMapKitに追加されました。以前は、地図を利用するにはUIViewRepresentableを使う必要があったのでちょっと面倒だったのですが、これでとても楽になりました!

ということで、今回は SwiftUI 2.0 & MapKit を利用して、地図アプリを作ってみようと思います!

とりあえずの完成形

今回はこんな感じのものを作ってみたいと思います。


【SwiftUI 2.0】MapKit で現在地とAnnotaionを表示する

GitHubはこちらです github.com

ざっくり機能

  • 位置情報の取得
    • デフォルトではおおよその位置情報を取得する
    • 正確な位置情報をリクエストする
  • 地図を表示する
  • 現在地ボタン
    • タップしたら現在地を表示する
  • クリアボタン

開発環境

参考書

SwiftUIの勉強には、この本が特におすすめです!

基礎から学ぶ SwiftUI

基礎から学ぶ SwiftUI

  • 作者:林晃
  • シーアンドアール研究所
Amazon

位置情報の扱いなどはこちらを参考に

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に指定しています。

地図を表示するだけであれば、これだけで完成です。とても簡単ですね!

f:id:hoshi0523:20201206115928p:plain

おおよその現在地を表示するための準備

続いて、おおよその現在地を表示してみます。iOSアプリでは「正確な現在地」と「おおよその現在地」を取得することができますが、今回はデフォルトを「おおよその現在地」とします。

Info.plistに以下のように項目を追加します。

f:id:hoshi0523:20201206120855p:plain

f:id:hoshi0523:20201206120908p:plain

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に与えています。これで、おおよその位置情報を取得し、地図に反映するようになりました。

f:id:hoshi0523:20201206122138p:plain

正確な位置情報を利用するための準備

正確な位置情報を利用したい場合には、その権限のリクエストを行う必要があります。まずはInfo.plistに「正確な位置情報を利用する理由」を追加します。

f:id:hoshi0523:20201206122924p:plain

理由は複数設定可能になっているため、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 {
    // ~省略~

これで、正確な位置情報をリクエストするようになりました。

f:id:hoshi0523:20201206123701p:plain

現在地をリクエストするメソッド

地図を自由に操作した後、表示位置を現在地に戻すための機能を追加します。まずは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で囲い、前面の右下にボタン領域を表示しています。ボタンをタップしたら、先程用意したメソッドを呼び出すようにしています。

これで、ボタンをタップすれば現在地が中央に表示されるようになりました。

f:id:hoshi0523:20201206125343g:plain

位置情報を管理する構造体を追加

位置情報を保持するための構造体を追加します。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()

f:id:hoshi0523:20201206131045g:plain

これで、地図を移動するたびにマーカーが表示されるようになりましたね!

任意のマーカービューを作成

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になりました。

f:id:hoshi0523:20201206132140g:plain

マーカーの削除

MapViewModelitemsを初期化するメソッドを追加し、画面上にそのメソッドを呼び出すボタンを配置します。

/// 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)
    }
}

MapViewMapAnnotationViewを呼び出している箇所にも修正が必要です。まずは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
        )
    }
}

これで、タップされたアイテムが詳細ビューとして表示されるようになりました!

f:id:hoshi0523:20201206134327g:plain

まとめ

SwiftUI 2.0 で用意されたMapは、MKMapViewとはちょっと使い方は異なりますが、位置情報の取得などの階層についてはこれまで通りの処理となります。

地図の表示のやり方、マーカーの使い方など、こちらを参考にやってみてください!

SwiftUI のPathとカスタムシェイプで独自のタブバーを作ってみる

カスタムシェイプとアニメーション

SwiftUIでは、Circle()Capsule()のように初めから用意されているシェイプの他にも、Shapeプロトコルを実装して独自にシェイプを作成することができます。

さらには、そのシェイプにアニメーションを与えることもできるので、早速試してみたいと思います。

とりあえずの完成形

今回はこんな感じのものを作ってみたいと思います。 f:id:hoshi0523:20201103121707g:plain

GitHubはこちらです github.com

開発環境

参考書

SwiftUIの勉強には、この辺りの本が特におすすめです!

基礎から学ぶ SwiftUI

基礎から学ぶ SwiftUI

  • 作者:林晃
  • シーアンドアール研究所
Amazon

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()
}

出来上がりはこんな感じです。タブをタップしても何も起きませんね。

f:id:hoshi0523:20201103123241p:plain

タブの位置を取得する

タップされたタブの位置情報を取得するための処理を追加します。まずはその位置情報を保持するためのプロパティを@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)
}
  1. GeometryRenderを利用して、ボタンを囲っています
  2. ボタンがタップされたら、そのボタンのX軸方向の中心座標を保持します
  3. frameには直接数値を与えるのではなく、親ビューの値を利用するようにしています
  4. 画面表示のタイミングで、一番左のタブの座標情報を初期値として保持しています
  5. GeometryRenderframe情報を与えています

保持している値を確認できるように、デバッグ用のTextを追加しています

    // ~ 省略 ~

    // TODO: content body.
    Spacer()

    // debug.
    Text("\(tabMidX)")
}

できあがりはこんな感じです。
選択されたタブのポジションが取得できているようですね!

f:id:hoshi0523:20201103125252g:plain

簡単なカスタムシェイプを作ってみる

ではカスタムシェイプを作ってタブ部分に適用してみます。

まずは既存のシェイプを返すだけのカスタムシェイプを作ってみます。カスタムシェイプは、Shapeを実装した構造体を作成し、pathというメソッドを実装します。

struct TabShape: Shape {
    func path(in rect: CGRect) -> Path {
        // とりあえずCapsuleを返すだけ.
        Capsule().path(in: rect)
    }
}
.background(
    Color.accentColor
        // 作成したシェイプでclipShapeを行う.
        .clipShape(TabShape())
        .ignoresSafeArea()
)

出来上がりはこんな感じです。見事にCapsuleでクリップされましたね。

f:id:hoshi0523:20201103125849p:plain

独自のカスタムシェイプを作ってみる

では改めてカスタムシェイプを作成していきます。まずは基本となる矩形を描画します。

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()
)

曲線を描画する

曲線のサイズやそれぞれのコントロールポイントのポジションがどのように算出されているかは、下図を参考にしてください。

f:id:hoshi0523:20201103133241p:plain

では実際に曲線を描画します。

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)
}

出来上がりはこんな感じです。選択されたボタンに合わせて、カスタムシェイプが描画されていますね。

f:id:hoshi0523:20201103133918g:plain

カスタムシェイプにアニメーションを与える

カスタムシェイプにアニメーションを与えるには、カスタムシェイプ内に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: {
    // ~省略~

出来上がりはこんな感じです。アニメーションを伴って変化するようになりましたね!

f:id:hoshi0523:20201103135012g:plain

それぞれのタブにアイコンを加える

では、選択中のタブにアイコンを表示してみます。まずは選択中のタブを保持するためのプロパティを@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
            // ~省略~
    }
}

出来上がりはこんな感じです。選択されたタブに合わせて、アイコン画像がアニメーションを伴って現れるようになりました!

f:id:hoshi0523:20201103140044g:plain

コンテンツ部分の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.plistView 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)

これで完成です!出来上がりはこんな感じです!

f:id:hoshi0523:20201103121707g:plain

まとめ

カスタムシェイプを使えば、独自の計上の背景などをたやすく作ることができます!アニメーションを与えることもできますので、色々と試してみてください。

今回作ったアプリのGitHubはこちらです。よかったら参考にしてみてください! github.com

SwiftUI 2.0 でカスタムタブビューを作ってみた

SwiftUI 2.0で追加されたAPI

SwiftUI 2.0 で追加されたPageTabViewStyleを使ってみたかったので、カスタムタブのようなものを作ってみました。

とりあえずの完成形

こういったものを作っていこうと思います。 999.gif

GitHubはこちらです。 github.com

開発環境

参考書

SwiftUIの勉強には、この辺りの本が特におすすめです!

基礎から学ぶ SwiftUI

基礎から学ぶ SwiftUI

  • 作者:林晃
  • シーアンドアール研究所
Amazon

事前準備

タブ部分の作成

スクリーンショット 2020-10-21 21.12.23.png

アニメーション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)
    }
}

出来上がったのはこちらです。このままだと、選択状態がよくわからないですね。 001.gif

選択状態がわかるように見た目を調整する

選択時/非選択時で見た目を切り替えるため、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に.
           }
       }
}

見た目はこのようになります。選択状態が一目でわかるようになりました。 002.gif

背景色の設定と、タブの配置調整

ContentViewの見た目を調整します。

  • 全体をZStackで囲う
  • 最背面にColor("bg").ignoresSafeArea()を配置して背景色とする
  • タブビュー部分をVStackSpacerを利用して画面下部に配置
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)
        }
    }
}

見た目はこうなりました。それっぽくなってきましたね。 003.gif

画面をタブで切り替える

タブは用意したので、このタブに連動して画面が切り替わるようにします。

まずはダミーで画面部分を用意します。
適当なので、こちらは好きに作ってもらって良いと思います。

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を作ってみます。

https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7478/bf3ebbd6-0ab7-922c-1532-cd1b86dc1333.gif

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)
            }
        }
    }
}

では動かしてみます。 0002.gif

選択状態を切り替えて、見た目も変わるようになりましたね。では、ここからアニメーションを追加していきましょう。

アニメーションさせる

まずはボタンの選択時の状態変化がアニメーションを伴うように、ボタンタップ時の挙動を一部修正します。

// 一部抜粋.
            
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アニメーションはテンション上がるので、他にも色々と試して記事にしたいと思います!

こちらの書籍はとても参考になったのでおすすめです!

基礎から学ぶ SwiftUI

基礎から学ぶ SwiftUI

  • 作者:林晃
  • シーアンドアール研究所
Amazon

やれたかも委員会 をリリースしました!

新作アプリを公開しました

f:id:hoshi0523:20180503135625p:plain:w300

みなさま、いつもご利用頂きありがとうございます。
Susumu Hoshikawaでございます。

今回は、新作マンガアプリ やれたかも委員会 のリリースご報告です!

やれたかも委員会

やれたかも委員会

  • Susumu Hoshikawa
  • ブック
  • 無料

ドラマでも絶賛放送中!

yaretakamoiinkai.com

現在、TBSにて、ドラマでも絶賛放送中の やれたかも委員会 を、この度マンガアプリとしてリリースさせていただきました!

やれたかも委員会って?

すでにTVでもご覧になった方もいらっしゃると思いますが、吉田貴司さん原作のマンガで、現在はnoteやマンガ on ウェブなどで連載中の作品です。

毎回、女性との「やれたかもしれない」(セックスできたかもしれない)思い出を持つ男性が、面接会場のような場所を訪れ、女性と甘酸っぱさやほろ苦さ残る思い出を独白形式で語る。聞き手である男性2名、女性1名で構成される「やれたかも委員会」メンバーが、最後に「やれた」か「やれたとは言えない」かを判定する。(wikipediaより抜粋)

第1巻は完全に無料で!

現在は2巻まで刊行中ですが、第1巻は完全無料となっております。第2巻の価格は¥240となっております。

ぜひこの機会にお試しください!

やれたかも委員会

やれたかも委員会

  • Susumu Hoshikawa
  • ブック
  • 無料

【ついに完結】特攻の島 最終巻 第9巻を公開しました

みなさま、いつも当アプリをご利用頂きまして、ありがとうございます。Susumu Hoshikawaでございます。

今回は、拙作アプリ無料で1巻!特攻の島に関するご報告です。

特攻の島

特攻の島

  • Susumu Hoshikawa
  • ブック
  • 無料

特攻の島 最終巻 ついに完結

佐藤秀峰さんが手がける漫画特攻の島が、長年の連載を経て、ついに完結いたしました。

そして、その最終巻である第9巻が、iPhoneiPadアプリ特攻の島にて、本日より配信を開始いたしました!

漫画「特攻の島」は、特攻兵機「回天」の搭乗員の姿を描いた物語で、当然舞台は太平洋戦争真っ只中。最後の作戦に挑む渡辺の運命は!?是非ともご覧下さい!

最終巻の表紙はこんな感じ!

最終巻の表紙はこのようになっています。

第1巻では、表情に幼さも残していた渡邊でしたが、物語をしめくくるこの最終巻で、何かを悟るような表情を見せています。どのような形で物語が完結するのかは、ぜひ皆様自身の目でお確かめください。

f:id:hoshi0523:20180221221906j:plain:w300


価格は¥240!

第2〜8巻と同様、価格は¥240となっております。書籍版と比べると、なんと半額以下です!

場所もとらずに安くあがる、いつでもどこでも読めるiPhone/iPadアプリ版の特攻の島、どうぞお楽しみください!

特攻の島

特攻の島

  • Susumu Hoshikawa
  • ブック
  • 無料

以上、今後とも当アプリをよろしくお願いいたします!