🦋
SwiftUI: へぇボタンっぽいキーボード
音も鳴っています。
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