🦋

SwiftUI: ピアノの鍵盤をレイアウトする

2023/01/22に公開

ピアノの鍵盤は白鍵と黒鍵がかさなっていたり、黒鍵が歯抜け状態になっていたりするため、単純にレイアウトすることが難しいです。が、工夫することでシンプルな構造でレイアウトすることが可能です。

要点

  • 鍵盤の左右方向の重なりは、鍵盤の横幅を半分にしたViewを並べ、実際の描画にはViewの倍の幅でPathに描画させることで解決
  • 鍵盤の奥行き方向の重なりはzIndex()で解決
  • 全体をリサイズ可能にするために、鍵盤の大きさや全体のサイズ指定にはaspectRatio()を用いる
KeyState
import SwiftUI

struct KeyState: Hashable, Identifiable {
    enum KeyType {
        case white
        case black
    }

    let id: UUID
    let keyType: KeyType

    init(keyType: KeyType) {
        self.id = UUID()
        self.keyType = keyType
    }

    var marginRatio: Double {
        return keyType == .white ? 0.1 : 0.3
    }

    var lengthRatio: Double {
        return keyType == .white ? 1.0 : 0.6
    }

    var color: Color {
        return keyType == .white ? Color.white : Color.black
    }

    var zIndex: Double {
        return keyType == .white ? 0.0 : 1.0
    }
}
KeyShape
import SwiftUI

struct KeyShape: Shape {
    var keyState: KeyState

    // このPathは与えられたrectの2倍の幅を描画領域として扱う
    func path(in rect: CGRect) -> Path {
        let w = rect.size.width
        let h = rect.size.height
        let m = keyState.marginRatio * w
        let l = keyState.lengthRatio
        let r = w / 5.0
        var path = Path()
        path.move(to: CGPoint(x: m, y: 0))
        path.addLine(to: CGPoint(x: 2 * w - m, y: 0))
        path.addLine(to: CGPoint(x: 2 * w - m, y: l * h - r))
        path.addArc(center: CGPoint(x: 2 * w - m - r, y: l * h - r),
                    radius: r,
                    startAngle: Angle(degrees: 0),
                    endAngle: Angle(degrees: 90),
                    clockwise: false)
        path.addLine(to: CGPoint(x: m + r, y: l * h))
        path.addArc(center: CGPoint(x: m + r, y: l * h - r),
                    radius: r,
                    startAngle: Angle(degrees: 90),
                    endAngle: Angle(degrees: 180),
                    clockwise: false)
        path.closeSubpath()
        return path
    }
}
KeyboardView
struct KeyboardView: View {
    @Binding var keys: [KeyState]

    var body: some View {
        ZStack {
            HStack(spacing: 0) {
                ForEach(Array(keys.enumerated()), id: \.element) { (offset, keyState) in
                    KeyShape(keyState: keyState)
                        .fill(keyState.color)
			// 1鍵盤の高さを1とした時の幅を0.16として、その半分
                        .aspectRatio(0.08, contentMode: .fit)
                        // 黒鍵はzIndexで手前に描画する
			.zIndex(keyState.zIndex)
		    // 黒鍵の歯抜けの箇所を補うためにダミーのViewを配置する
                    if [4, 11].contains(offset % 12) || offset == keys.count - 1 {
                        Rectangle()
                            .aspectRatio(0.08, contentMode: .fit)
                            .hidden()
                    }
                }
            }
        }
	// 3.52 = 0.16 × (7白鍵 × 3オクターブ + ラスト1鍵盤)
        .aspectRatio(3.52, contentMode: .fit)
    }
}
ContentView
import SwiftUI

struct ContentView: View {
    @State var keys: [KeyState]

    init() {
        // 1オクターブ(0~12)の範囲での黒鍵のインデックス
        let blacks: [Int] = [1, 3, 6, 8, 10]
        // 12鍵盤 × 3オクターブ + ラスト1鍵盤
        keys = (0 ..< 37).map { i in
            // インデックスを12で割った余りで白鍵と黒鍵を判断
            return KeyState(keyType: blacks.contains(i % 12) ? .black : .white)
        }
    }

    var body: some View {
        KeyboardView(keys: $keys)
    }
}

Discussion