【iOSDC2024 #LT】3. Layout Protocolを使って、ピアノのUIを作ってみた
この記事はiOSDC2024で発表したLTのセッション、我が家に電子ピアノがやってきたので、ピアノと連携するアプリを作ってみた!の発表のうち、ピアノのUIの実装方法についてまとめたものです
前回の記事は、「2. Core MIDIを使って、電子ピアノと連携したアプリを作る」でした
前回の記事でも掲載しましたが、今回実装したピアノのUIは以下のようなものです
このキャプチャでは見切れていますが、もちろん実際のピアノを再現したUIなので、横スクロールして、左右の鍵盤を表示することができます
表示できる鍵盤は、Note番号に合わせて、-1オクターブのドから、9オクターブのシの範囲のものです
このUIをどのように実装したかをこの記事では紹介します
なおこのUIについては、PianoUIというPackageに実装をまとめています
ピアノのUIの実装
各鍵盤のUI実装
まずはコンポーネントとして、1つ1つの鍵盤のUIの実装をします
ピアノの鍵盤は白と黒の鍵盤の2種類があるので、PianoKeyViewというenumの中に、WhiteとBlackの2種類のViewを定義しています
基本的にはベースとして、UnevenRoundedRectangleを使用し、bottom側のみcornerRadiusを設定したViewを設定しています
その上に鍵盤を叩いていることがわかるように、ColorのViewを重ねています
またGarageBandのピアノ鍵盤のUIと同様に、初期設定で白の鍵盤の各オクターブの先頭のドの鍵盤の下部には、その音名を表示しています(設定によっては、すべての鍵盤に音名を表示できるようにしている)
サイズに関しては、ピアノの実際の鍵盤のAspect比で表示されるように、.aspectRatioを使用しています
またこのUIはタップした時に電子ピアノ側へそのイベントを出力する機能をサポートしているので、ユーザーがタップした時にタップした情報を送信する必要があります
音名はPianoKeyViewの初期化時に設定するので渡すことができますが、難しいのは鍵盤を叩く強さと離した時のイベントの送信です
SwiftUIだけでは画面をタップした時の強さを取得できない(はずな)ので、この機能をサポートするにはUIKitを使右必要がありますが、今回その機能はサポートせず、設定画面から送信する値を固定値で指定するようにしています
鍵盤を叩くand離すイベントについてですが、onTapGestureだとその両方のイベントを取得することはできません
そのためiOSアプリではDragGestureを使用しています
離した時のイベントについては.onEndedの時に送信し、叩いた時のイベントは.onChangedを利用していますが、そのイベントはタップ(ドラッグ)している間、継続的に送信されてしまうため、MidiHelperに送る前に、CombineのremoveDuplicates()を利用し、同じ鍵盤のイベントが重複して送信されることを防いでいます
なお重要なのは、このDragGestureを宣言する時に、.gestureではなく、.simultaneousGestureを利用する必要があることです
これはPianoViewが内部でScrollViewを利用しているので、.gestureのままだとこのScrollViewのスクロールが機能しなくなってしまいます
なお先ほどiOSアプリでは、と記載がありましたが、visionOSではうまく動かなかったので、それは別のVisionOS対応の記事でまとめます
鍵盤全体のUIの実装
次がメインの鍵盤全体のUIについてです
単純に考えると、上記のPianoKeyViewを横方向に並べればいいのでは?と思いますが、これがそう単純ではありません
実際のピアノの鍵盤は鍵盤間に少し隙間が開いているので、少しspacingを追加する必要があります
そして特に難しいのが、実際のピアノの鍵盤を見てみると、黒鍵盤は白い鍵盤の境界の中央に位置しているのではなく、実は少し左右にずれており、それも音名によって少し異なります
また今回はViewのサイズは固定値にしておらず、今回実装したアプリはもちろん他機種で使用することを想定していますし、発表スライド内にもこのPianoViewを埋め込む想定だったので、どのような表示サイズを指定も鍵盤のアスペクト比を維持した状態で表示する必要があります
そのためそれらを標準のHStackやZStackで実装すると、かなり複雑な実装になってしまいます
最初はGeometryReaderを使用して実装しましたが、GeometryReaderを利用すると、PianoViewを利用した側もPianoViewのサイズを正しく設定しないとPianoViewが親Viewが期待するサイズを超えて描画されることもあり、レイアウトの実装がかなり難しくなります
そこで今回利用したのが、Layout Protocolです
このLayoutはiOS16から利用できるProtocolで、標準のHStackやVStackなどを組みあわせるだけではUI実装が難しい場合に、このLayoutを利用することで複雑なカスタムのレイアウトを表示できるコンテナを実装することができます(最終的なコードは、こちらで確認できます)
これを利用すれば、自作でHStackやVStackなどのコンテナを実装することができます
このProtocolに準拠するには2つの関数を定義する必要があります
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Self.Subviews,
cache: inout Self.Cache
) -> CGSize
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Self.Subviews,
cache: inout Self.Cache
)
sizeThatFitsはこのコンテナのサイズを定義することができます
proposalは親Viewなどを考慮して、SwiftUI側が提案するサイズを取得することができます
今回実装したPianoViewではPianoLayoutというLayoutを定義し、sizeThatFits内で以下のような実装をしています
private let whiteKeyCount = KeyType.allCases.filter(\.isWhiteKey).count * Octave.allCases.count
public func sizeThatFits(
proposal: ProposedViewSize,
subviews: Self.Subviews,
cache: inout Self.Cache
) -> CGSize {
let whiteKeyWidth = (proposal.height ?? 0) / AspectRatioConstants.whiteKeyAspectRatio.height * AspectRatioConstants.whiteKeyAspectRatio.width
let width = whiteKeyWidth * CGFloat(whiteKeyCount) + CGFloat(whiteKeyCount - 1) * keyMargin
return proposal.replacingUnspecifiedDimensions(by:
.init(
width: width,
height: proposal.height ?? 0
)
)
}
PianoViewはScrollViewを利用しているのでこのproposalで取得できるサイズはそのScrollViewの表示上のサイズを返却します
sizeThatFitsではスクロールした時に表示されるピアノの鍵盤全体のサイズを返す必要があるので、heightについては、proposalのheightをそのまま利用します
widthについては白い鍵盤×その個数(今回は-1~9オクターブ×ドレミファソラシの77個)とその間のspace分を加味したものになります
白い鍵盤1つのwidthはheightから鍵盤のAspect比を考慮して、計算できます
そのwidthを個数分合計し、そして鍵盤の数-1の分のspace(今回は1)を足したものを全体のwidthとして利用します
もう一つのメソッドのplaceSubviewsは、具体的にViewの配置位置を決定します
コンテナの範囲内で具体的に座標を指定して、subViewが持つLayoutSubViews.Element型のplace関数を利用して配置します
今回PianoLayoutを使ったPianoViewでは以下のようにすべてのPianoKeyViewをForEachでloopして順番に宣言し、PianoLayoutのコンテナに渡しています
private let pianoKeys = PianoKey.allCases
ScrollView(.horizontal) {
PianoLayout(pianoKeys: pianoKeys) {
ForEach(pianoKeys) { pianoKey in
if pianoKey.keyType.isWhiteKey {
PianoKeyView.White(...)
.id(pianoKey)
} else {
PianoKeyView.Black(...)
.zIndex(1)
}
}
}
}
そのためplaceSubviewsのsubViewsで渡されるViewは上記のPianoKeyViewの配列になります
それを考慮して、今回は以下のような実装をしています
// Assigns positions to each of the layout’s subviews.
public func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Self.Subviews,
cache: inout Self.Cache
) {
guard !subviews.isEmpty else { return }
let whiteKeyWidth = bounds.height / AspectRatioConstants.whiteKeyAspectRatio.height * AspectRatioConstants.whiteKeyAspectRatio.width
let blackKeyWidth = whiteKeyWidth / AspectRatioConstants.whiteKeyAspectRatio.width * AspectRatioConstants.blackKeyAspectRatio.width
// For some reason, the leading part is cut off from the screen by the width of the white keys * 0.5, so adjust it manually.
var whiteKeyX: CGFloat = bounds.minX + whiteKeyWidth / 2
subviews.indices.forEach { index in
let pianoKey = pianoKeys[index]
if pianoKey.isWhiteKey {
// Place only if subview is within view range
guard whiteKeyX + whiteKeyWidth > bounds.minX, whiteKeyX < bounds.maxX else { return }
// Place subview
subviews[index].place(
at: CGPoint(x: whiteKeyX, y: bounds.minY),
anchor: .top,
proposal: .init(
width: whiteKeyWidth,
height: whiteKeyWidth * AspectRatioConstants.whiteKeyAspectRatio.width * AspectRatioConstants.whiteKeyAspectRatio.height
)
)
// Shift the y coordinate by the height of the placed View
whiteKeyX += whiteKeyWidth
// Calculate the spacing between the next View and add it to the y coordinate
let nextIndex = subviews.index(after: index)
if nextIndex < subviews.endIndex {
whiteKeyX += keyMargin
}
} else {
let blackKeyOffset = blackKeyWidth * pianoKey.keyType.keyOffsetRatio
// For some reason, the leading part is cut off from the screen by the width of the white keys * 0.5, so adjust it manually by minus (whiteKeyWidth / 2) .
let blackKeyX = whiteKeyX - whiteKeyWidth / 2 - (keyMargin / 2) - blackKeyWidth / 2 + blackKeyOffset
// Place only if subview is within view range
guard blackKeyX + blackKeyWidth > bounds.minX, blackKeyX < bounds.maxX else { return }
subviews[index].place(
// Adjust the black keys by 0.5 more than the amount of the white keys that you adjusted manually.
at: CGPoint(x: blackKeyX, y: bounds.minY),
anchor: .topLeading,
proposal: .init(
width: blackKeyWidth,
height: nil // Calculate height by aspectRatio
)
)
}
}
}
まずは白い鍵盤と黒い鍵盤のwidthを計算します
boundsは表示するviewのサイズ、今回だとsizeThatFitsで返却した鍵盤全体のサイズを取得できるので、そこから取得できるheightから、それぞれのwidthを取得します
次にsubViewsをloopして、順番に個別にViewを配置していきます
indexから現在のloopのPianoKeyを取得できます
まずは白い鍵盤を順番に並べ、その次にその上に黒い鍵盤を並べるようにします
y座標はbounds.minYで固定なので、計算するのはx座標のみです
x座標を計算するにはforEach外で定義したwhiteKeyXにloopごとに白い鍵盤のwidthと鍵盤間のspaceを追加していき、その値に応じてplace関数でsubViewを配置していきます
注意点としてはそのまま表示すると、白い鍵盤の半分のサイズだけleading方向の画面外に見切れる現象が起きているので手動で0.5個分、+方向に調整しています(現時点で原因はわかっていません)
その後に黒い座標を並べます
白い座標の計算に利用したwhiteKeyXをもとに、まずは白い鍵盤の左上に黒い鍵盤が配置されるように調整します
let blackKeyX = whiteKeyX - whiteKeyWidth / 2 - (keyMargin / 2)
whiteKeyWidth / 2の分は既述の通り、画面外に白い鍵盤の半分が見切れてしまう分を考慮したものです
(keyMargin / 2)の部分は、鍵盤間のスペースを差し引いたものです
ポイントはanchor: .topLeadingと指定することです
こうすることで、subViewが左上にalignされた位置に配置されます
その後、黒い鍵盤の半分を左側にずらし、鍵盤の位置に応じて、それぞれ微調整すれば完成です
let blackKeyX = whiteKeyX - whiteKeyWidth / 2 - (keyMargin / 2) - blackKeyWidth / 2 + blackKeyOffset
あとは黒い鍵盤は、.zIndex(1)を指定することで、白い鍵盤よりも上に表示することができます
最後に描画パフォーマンスを考慮します
今回のPianoViewはScrollViewを使用しているので、基本的にsizeThatFitsで指定したサイズのView全体が実際に表示されているわけではありません
そのため表示されていないViewは配置する必要はありません
そのため今回は以下のように、表示エリア外のsubViewは配置しないように実装しています
guard whiteKeyX + whiteKeyWidth > bounds.minX, whiteKeyX < bounds.maxX else { return }
これでPianoViewの実装は完了です🎉
ちなみにPianoViewの定義で、白鍵盤のViewに.id(pianoKey)を設定しているのは、
ScrollViewReaderを使用し、Viewの表示時に指定のPianoKeyの位置を初期表示位置として指定できるようにするためです
まとめ
今回の記事ではLayout Protocolを使ったピアノのUIの実装についてまとめました
次の記事は「4. SwiftUIのアニメーションでスライド演出を作ってみた」