LoginSignup
49
40

More than 3 years have passed since last update.

SwiftUIとCombineの練習がてらmacOS向けのピアノアプリ作ってみた

Last updated at Posted at 2020-09-12

はじめに

SwiftUIとCombine、Swiftコーダーのみなさまは勉強なさっているでしょうか?私は今まで近いうちに勉強しよう明日やろうと言いながら、アプリのメンテを優先してずっと後回しにしてきていました。しかし、ついに会社でも使う機会が訪れ、ようやく重い腰を上げ勉強し始めました。ただ、文法や仕組みを学ぶだけでは全く身につかないなぁと思ったので、作りたいものを決めて実装しながら学ぶことにしました。

今回は、ある程度複雑な形状のViewを複数組み合わせる必要があり、Viewに対するアクションに基づいてリアルタイムに動作が変わるものが良さそうだったので、ピアノのシミュレーターアプリを作る事にしました。

成果物 PianoSample

⬇️デモ動画リンク
Piano Demo
ウィンドウにピアノの鍵盤が表示され、クリックやドラッグをする事で実際に音を鳴らして弾くことができるアプリを作りました。開発にかかった時間は10時間くらいだったと思います。コードはGitHubに置いてあります。
https://github.com/Kyome22/PianoSample

学び(SwiftUI編)

「ピアノの鍵盤をドラッグしている時にカーソルが鍵盤の上にある時はViewが押されているよう描画し、カーソルが鍵盤の上から外れた場合にはViewが押されていないよう描画する」という機能を実装する際にかなりハマりました。

関門1

  • Viewに対するアクションのイベントハンドラには、onTapGestureonLongPressGestureはあるが、タッチの開始と終了を検知して処理を実行する術がデフォルトで容易されていない

この問題に対処するため、DragGestureを用いてドラッグ中のタッチ点の座標を取得して、Viewに対する当たり判定を行う事にしました。

@State var location: CGPoint = .zero

let drag = DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
    .onChanged({ drag in
        self.location = drag.location
    })
    .onEnded({ _ in
        self.location = .zero
    })

関門2

  • Viewでは@Stateをつけた変数を宣言すると、その値を変更することが可能になり、変更された際にViewが再描画される
  • Viewの再描画中には@Stateの変数の値を変更してはいけない

この二つの法則から、ある変数に従ってViewを描画したいが、その変数の更新はViewの描画時にしかできないという状態に陥りました。具体的には、上の当たり判定をスクラッチで実装するために、Viewのサイズを取得したかったのですが、それをするにはGeometryReaderなるものでViewの要素を包んでGeometryProxyを経由してViewのサイズを取得する必要がありました。

var body: some View {
    GeometryReader { geometry in
        let frame = geometry.frame(in: .global) // とか
        // なんかView
    }
}

そこで、GeometryProxyを用いてViewのサイズを取得して、当たり判定をしようと思ったのですが、上のジレンマ状態に陥ったのです。

struct SomethingView: View {
    @State var isHit: Bool = false
    @Binding var location: CGPoint

    var body: some View {
        GeometryReader { geometry in
            self.hitTest(geometry: geometry)
        }
    }

    func hitTest(geometry: GeometryProxy) -> some View {
        let frame = geometry.frame(in: .global)
        isHit = frame.contains(location) // こんな感じでisHitを更新しようとするとエラーになる
        if isHit {
            return Color.black
        } else {
            return Color(white: 0.2)
        }
    }

}

isHithitTestのローカル変数にすればいいのでは?と思うかもしれませんが、後でこのisHitの状態に応じて音を再生/停止するイベントを発火したかったので、外で持つ必要がありました。

この問題に対しては、Viewで何らかのModelを持ち、その中でisHitを持つという方法で解決しました。

関門3

  • DragGesture.Value.locationは左上が原点
  • GeometryProxyで取得できるframeは左下が原点
  • ShapeのPathのcontains(CGPoint)による当たり判定は上下が反転している(?)

ここで、macOSとiOSの座標系のずれの問題が発生して、当たり判定がかなり厄介になっていました。SwiftUIの座標系が左上原点なのか左下原点なのか、使う要素のメソッドによりブレているのが辛かったです。

この問題は、愚直にViewの高さを取得してY座標を反転したり、Pathの上下を一時的に反転して当たり判定を行ったりして解決しました。

テクニック

Viewの四隅を丸くしたい場合は、cornerRadius()を使えばいいのですが、四隅の一部だけを丸くしたい場合には簡単に実装できる方法がありません。そこで、カスタムのShapeを実装して好きな形のViewを描画するようにしました。Pathを用いて特殊な形状のViewへの当たり判定も実装できるので今回は一石二鳥でした。

struct CustomShape: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        // ↓ここら辺のメソッドを使ってPathを描く
        path.move(to: CGPoint)
        path.addArc()
        path.addLine(to: CGPoint)
        path.closeSubpath()
        return path
    }
}

基本的にはUIBezierPathやNSBezierPathと同じ扱い方です。座標系には気をつけましょう。

学び(Combine編)

今回のピアノ実装の最大の難点は、 SwiftUIのViewにonTapGestureonLongPressGestureのようなイベントハンドラとしてタッチの開始や終了を起点に発火するやつがなかったことでした。自前でイベントハンドラを作らねばなりません。そのため、当たり判定が更新された際に、音の再生停止をするために命令を伝達するルートをCombineで作りました。必要なことは以下の3つでした。

当たり判定の更新時に音の再生停止を伝えるSubjectを用意してsend()する仕組みを用意

class CustomModel: ObservableObject {
    let subject = PassthroughSubject<Bool, Never>()
    var isHit: Bool = false {
        didSet {
            if oldValue != isHit {
                if isHit {
                    play()
                } else {
                    stop()
                }
            }
        }
    }

    func play() {
        subject.send(true)
    }

    func stop() {
        subject.send(false)
    }
}

subjectのsend()を受け取ってイベントハンドラを発火する仕組みを用意

struct CustomView: View {
    let model: CustomModel

    init(model: CustomModel) {
        self.model = model
    }

    // 中略

    func onEvent(handler: @escaping ((Bool) -> Void)) -> some View {
        return self.onReceive(model.subject, perform: { (flag) in
            handler(flag)
        })
    }
}

イベントハンドラの登録

struct HogeView: View {
    var body: some View {
        CustomView(model: CustomModel())
            .onEvent(handler: { (flag) in
                // flagにしたがって音を鳴らす処理
                print("ド♪")
            })
    }
}

学び(AVAudioUnitSampler編)

ピアノの音を鳴らすには、AVAudioUnitSamplerを用いました。基本的な方法はこちらの記事にまとめてあります。今回は単音ではなく和音も鳴らせるようにしたかったので、AVAudioUnitSamplerを複数用意してAVAudioMixerNodeでミキシングする必要があるのかなと予想していたのですが、一つのAVAudioUnitSamplerで複数のノートを同時に鳴らすことができました。

ただ、かなり実装でてこずった点がありまして、普通にAVAudioUnitSamplerを使ってノートを再生するだけでは、どうしてもノートを停止した後にブツッというノイズが入るのです。AVAudio関連でフェード・イン/アウトする方法をかなり調べましたが、AVAudioPCMBufferを使って実際にフェード・イン/アウトするような音源をその場で生成するしかありませんでした(これはかなり計算コストを要するしノートごとに音源を作らねばならないのでかなり厄介です)。いろいろ試行錯誤したところAVAudioUnitSamplerにSoundFontを読み込ませれば、ノートの停止時にノイズが入らないことが判明しました。

ちなみに、音源ファイル(mp3とかcafとか)を読み込んで鳴らす手法を取らなかった理由ですが、ピアノの鍵盤を押している間は途切れることなく音が再生されるようにしたかったため、固定長の音源ファイルを使いたくなかったからです。

所感

SwiftUIを使ったViewのレイアウトはStoryboardを使うよりもわかりやすく、コードで全て完結できるのでメンテナンスがしやすそうだと思いました。一方で、少し複雑なViewを定義すると、型推論が解決できずにタイムアウトしてしまい、どこが不具合の原因なのかもわからないということに陥りやすく、開発に慣れるまでは修正が難しかったです。基本的にはViewは小さな単位で切り分けて定義するようにすると良さそうですね。

Combineについては、どこからがCombineの機能で、どこからがSwiftUIの機能なのかまだ曖昧ではっきりわかっていないので、勉強していきたいですね。おそらく代表的な情報の伝達パターンをいくつか覚えてしまえば、綺麗にイベントを捌けるようになりそうです。

49
40
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
49
40