🦋

SwiftUI: へぇボタンっぽいキーボード

2023/08/21に公開


音も鳴っています。

iOSのKeyboard Extensionで実装。

KeyboardViewModel
import Foundation

final class KeyboardViewModel: ObservableObject {
    let heeHandler: (String) -> Void

    @Published var isTouching: Bool = false
    @Published var count: Int = 0

    init(heeHandler: @escaping (String) -> Void) {
        self.heeHandler = heeHandler
    }

    func onTouchDown() {
        if isTouching { return }
        isTouching = true
        heeHandler("へぇ〜")
        count += 1
    }

    func onTouchUp() {
        isTouching = false
    }
}

へぇボタンっぽいボタンをSwiftUIのViewコンポーネントとSF Symbolsを駆使して再現。
ボタンを押した感じで見た目を動的に変更するために、ボタンとしての機能はonTapGesture()ではなくDragGestureで実装。

KeyboardView
import SwiftUI

struct KeyboardView: View {
    @StateObject var viewModel: KeyboardViewModel
    private let highlightColor = Color(red: 0.963, green: 0.961, blue: 0.447, opacity: 0.7)
    private let topColor = Color(red: 0.792, green: 0.826, blue: 0.851, opacity: 0.7)
    private let dotColor = Color(red: 0.824, green: 0.824, blue: 0.824)
    private let middleColor = Color(red: 0.239, green: 0.569, blue: 0.754)
    private let bottomColor = Color(red: 0.641, green: 0.785, blue: 0.873)

    var body: some View {
        ZStack(alignment: .top) {
            Ellipse()
                .frame(width: 120, height: 80)
                .foregroundColor(viewModel.isTouching ? highlightColor : topColor)
                .padding(.top, 20)
                .offset(y: viewModel.isTouching ? 10 : 0)
            VStack(spacing: 0) {
                Rectangle()
                    .frame(height: 60)
                    .foregroundColor(.clear)
                HStack(spacing: 18) {
                    ForEach(0 ..< 5, id: \.self) { _ in
                        Circle()
                            .frame(width: 15, height: 15)
                            .foregroundColor(dotColor)
                    }
                }
                .frame(width: 140, height: 50)
                .background(middleColor)
                ZStack {
                    Rectangle()
                        .frame(height: 60)
                        .foregroundColor(bottomColor)
                    HStack(spacing: 70) {
                        Image(systemName: "laurel.leading")
                            .font(.largeTitle)
                            .foregroundColor(highlightColor)
                        Image(systemName: "laurel.trailing")
                            .font(.largeTitle)
                            .foregroundColor(highlightColor)
                    }
                    HStack {
                        Text("へぇ")
                            .font(.callout)
                            .foregroundColor(.clear)
                            .frame(height: 40, alignment: .bottom)
                        Text(String(format: "%2d", viewModel.count))
                            .font(.title)
                            .monospaced()
                            .foregroundColor(Color.red)
                            .padding(.horizontal, 8)
                            .background(Color.black)
                            .frame(height: 60)
                        Text("へぇ")
                            .font(.callout)
                            .foregroundColor(middleColor)
                            .frame(height: 40, alignment: .bottom)
                    }
                }
            }
        }
        .frame(width: 170, height: 180)
        .shadow(radius: 5)
        .padding(4)
        .gesture(
            DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
                .onChanged { _ in
                    viewModel.onTouchDown()
                }
                .onEnded { _ in
                    viewModel.onTouchUp()
                }
        )
    }
}
KeyboardViewController
import UIKit
import SwiftUI

class KeyboardViewController: UIInputViewController {
    private let soundEngine = SoundEngine()
    private var keyboardViewModel: KeyboardViewModel!

    override func viewDidLoad() {
        super.viewDidLoad()
        keyboardViewModel = KeyboardViewModel(heeHandler: { [weak self] text in
            self?.textDocumentProxy.insertText(text)
            self?.soundEngine.play()
        })
        let keyboardView = KeyboardView(viewModel: self.keyboardViewModel)
        let hostingController = UIHostingController(rootView: keyboardView)
        addChild(hostingController)
        view.addSubview(hostingController.view)
        hostingController.didMove(toParent: self)
        hostingController.view.backgroundColor = UIColor.clear
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            hostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
            hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
            hostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

    override public func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        NSLog("🔨 Full Access \(hasFullAccess)")
        if hasFullAccess {
            do {
                try soundEngine.startEngine()
            } catch {
                NSLog("🔨 SoundKit \(error.localizedDescription)")
            }
        }
    }

    override public func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        soundEngine.stopEngine()
    }
}
おまけ:音を鳴らすやつ
SoundEngine
import AVFoundation

final class SoundEngine {
    private let engine = AVAudioEngine()
    private let player = AVAudioPlayerNode()
    private var file: AVAudioFile?
    private var needsFileScheduled: Bool = true

    init() {}

    func startEngine() throws {
        if engine.isRunning { return }

        try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)

        guard let url = Bundle.main.url(forResource: "hee", withExtension: "m4a") else {
            return
        }
        file = try AVAudioFile(forReading: url)
        engine.attach(player)
        engine.connect(player,
                       to: engine.mainMixerNode,
                       format: file!.processingFormat)
        try engine.start()
    }

    func stopEngine() {
        if engine.isRunning {
            engine.stop()
            engine.disconnectNodeOutput(player)
            engine.detach(player)
            player.reset()
        }
    }

    func play() {
        if engine.isRunning, let file {
            if player.isPlaying {
                player.stop()
            }
            player.scheduleFile(file, at: nil)
            player.play()
        }
    }
}

Discussion