StreamDeck (10) 키패드 기호 화면과 닿는 순간 오는 진동
이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.
원격까지 붙이고 나서 한동안 그냥 써 봤다. 큰 기능은 더 필요 없었는데, 손에 걸리는 게 두 가지 있었다.
- 무각 HHKB로 블로그 글을 쓸 때
-,`,|같은 기호를 찾기 어렵다. 숫자 키패드(6편) 옆에 기호도 있으면 좋겠다. - 아이폰은 버튼을 누르면 진동이 오는데, 손을 뗄 때 와서 눌렀다는 느낌이 늦다.
이번 글은 이 둘을 고친 기록이다.
찾기 어려운 기호를 모은 기호 화면
무각 HHKB로 블로그 글을 쓰다 보면 숫자보다 기호가 더 어렵다. 목록의 -, 코드의 `, 표의 |, 링크의 [ ]는 자주 쓰는데, 오른쪽 위에 몰려 있는 키라 손으로 더듬어 찾게 된다. 그래서 키패드에 “기호” 키를 두고, 누르면 숫자 자리가 기호 16개로 바뀌게 했다. “123”을 누르면 숫자로 돌아온다.
1
2
3
4
5
6
숫자 화면 기호 화면
7 8 9 - = \ `
4 5 6 _ + | ~
1 2 3 [ ] { }
Tab 0 ⌫ ; ' : "
[기호][ Enter ] [123][ ⌫ ][Enter]
기호 목록과 순서는 DeckKit에 한 곳으로 두었다. 아이패드 화면은 이 순서대로 4개씩 끊어 줄을 만들고, 맥은 이 목록에 있는 글자만 받는다.
1
2
3
4
5
/// 기호 화면의 키. symbols에 있는 글자만
case symbol(Character)
/// 기호 화면에 놓는 순서(4열). HHKB 오른쪽 위 묶음과 그 Shift, 마크다운에 쓰는 괄호와 따옴표
public static let symbols: [Character] = Array(#"-=\`_+|~[]{};':""#)
Array로 글자를 하나씩 나누기
Array(_:)에 글자열을 넘기면 글자(Character, 8편)를 하나씩 담은 배열이 된다. 기호를 하나씩 "-", "=", …로 적는 대신 한 줄로 적었다. 역슬래시와 따옴표가 섞여 있어서 3편의 raw string(#"…"#)으로 감쌌다.
1
2
3
4
5
private var symbolRows: [[TypedKey]] {
stride(from: 0, to: TypedKey.symbols.count, by: 4).map { start in
TypedKey.symbols[start..<start + 4].map(TypedKey.symbol)
}
}
stride로 4개씩 끊기
stride(from:to:by:)는 시작부터 끝 앞까지 정한 간격으로 건너뛰는 수를 준다. 여기서는 0, 4, 8, 12다. 각 수에서 4개씩 잘라 한 줄로 만들었다. .map(TypedKey.symbol)은 글자 하나하나를 .symbol(글자) 키로 바꾼다.
키 번호 대신 글자 그대로 보내기
6편의 숫자는 키 번호로 보냈다. 기호도 키 번호로 보낼 수 있지만 두 가지가 걸린다. 하나는 Shift다. _는 - 키에 Shift를 같이 눌러야 나오는데, 6편에서 맥에서 누르고 있는 키가 섞이지 않게 flags를 비워 두었다. 다른 하나는 한글 입력기다. macOS 한글 입력기는 기본 설정에서 ` 키를 누르면 ₩를 넣는다(설정에 “₩ 대신 ` 입력” 칸이 따로 있다).
그래서 기호는 8편의 입력칸처럼 글자 그대로 키 이벤트에 실었다. 키 번호는 아무 값(0)이나 두고, 넣을 글자를 keyboardSetUnicodeString(8편)으로 덧붙인다. 그러면 Shift도, 입력기 상태도 상관없이 그 글자가 들어간다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// 기호는 키 번호 대신 글자 그대로 싣는다. 한글 입력 상태에서 `가 ₩로 바뀌거나, Shift 기호가 자판에 따라 달라지지 않게
static func characters(for key: TypedKey) -> [UniChar]? {
guard case .symbol(let s) = key else { return nil }
return Array(String(s).utf16)
}
static func type(_ key: TypedKey) -> Bool {
guard MediaKey.allowed else { return false }
let characters = characters(for: key)
guard let code = keyCode(for: key) ?? (characters == nil ? nil : 0) else { return false }
let source = CGEventSource(stateID: .hidSystemState)
for down in [true, false] {
let event = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: down)
event?.flags = []
if let characters { event?.keyboardSetUnicodeString(stringLength: characters.count, unicodeString: characters) }
event?.post(tap: .cghidEventTap)
}
return true
}
숫자는 그대로 키 번호로 둔다. 윗줄 숫자 키는 한글 입력 상태에서도 숫자로 들어가서 바꿀 까닭이 없다.
예전 맥 앱과 섞일 때
메시지는 숫자와 같은 typeKey에 글자 하나로 싣는다. {"key":"~","type":"typeKey"} 같은 모양이다. 예전 맥 앱은 숫자가 아닌 글자를 모르니 이 메시지를 읽지 못하고 버린다. 망가지지는 않지만 눌러도 아무 일이 없으면 헷갈린다.
그래서 8편의 기능 번호를 3으로 올렸다. 맥 앱이 3보다 낮으면 “기호” 키를 흐리게 두고 누를 수 없게 했다. 연결 상태 아래의 “맥 TouchDeck을 새 버전으로 설치해 주세요”도 같이 뜬다.
| 번호 | 더해진 것 |
|---|---|
| 1 | 버튼, 볼륨, 키패드, 화면 보기 |
| 2 | 글자 입력(입력칸, 특수 키 줄) |
| 3 | 키패드 기호 |
맥도 목록에 없는 글자는 받지 않는다. 키패드로 아무 글자나 넣을 수 있게 되면, 화면을 보는 기기만 받던 글자 입력(8편)을 키패드가 우회하는 셈이 된다.
1
2
3
4
@Test func unknownSymbolIsRejected() {
#expect(throws: (any Error).self) { try ClientMessage.decode(#"{"key":"a","type":"typeKey"}"#) }
#expect(throws: (any Error).self) { try ClientMessage.decode(#"{"key":"가","type":"typeKey"}"#) }
}
닿는 순간 오는 진동
아이폰에서 스트림덱 버튼이나 키패드를 누르면 진동이 왔다. 4편에서 탭이 끝나는 순간 UIImpactFeedbackGenerator로 울리게 해 둔 것이다. 그런데 써 보니 실제 키보드를 누르는 느낌과 달랐다. 실제 자판은 키가 눌려 들어가는 순간 딸깍이 오는데, 이건 손을 뗄 때 온다. 화면 보기의 특수 키 줄과 새로 만든 “기호” 키에는 진동이 아예 없었다.
햅틱 피드백이란?
햅틱 피드백은 화면을 누른 손가락에 짧은 떨림으로 “눌렸다”를 알려 주는 것이다. 아이폰 안에는 Taptic Engine이라는 작은 진동 장치가 있어서, 길게 웅 하는 전화 진동과 달리 톡 하고 짧게 끊어지는 떨림을 낼 수 있다. 아이폰 자판이나 토글 스위치를 누를 때 오는 그 느낌이다. 아이패드에는 이 장치가 없어서 같은 코드를 불러도 아무 일도 없다. 그래서 4편에서 아이패드는 키캡이 눌려 들어가는 그림으로 대신 보여 줬다.
진동 세기와 모양은 다섯 가지(light, medium, heavy, soft, rigid) 중에 고른다. 처음에는 키에 rigid를 골랐다. 짧고 또렷해서 단단한 키캡을 누르는 느낌에 가까울 거라고 봤다. 그런데 아이폰에서 눌러 보니 약했다. 그래서 누르는 순간은 가장 묵직한 heavy로 바꿨다.
진동을 한 곳에 모으기
키마다 따로 만들던 진동을 한 곳으로 모았다. 키를 누르는 순간과, ⌫를 누르고 있어 연달아 지울 때의 톡톡 두 가지다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@MainActor
enum KeyHaptics {
/// 실기기에서 rigid는 약하다고 해서 heavy로 올렸다
private static let pressGenerator = UIImpactFeedbackGenerator(style: .heavy)
/// 연달아 울려도 하나하나 구분되게 짧고 또렷한 rigid. 0.4 세기는 실기기에서 안 느껴져 최대로 둔다
private static let tickGenerator = UIImpactFeedbackGenerator(style: .rigid)
/// 키가 눌려 들어가는 순간
static func press() {
pressGenerator.impactOccurred()
// 다음 누름이 늦지 않게 진동 장치를 깨워 둔다
pressGenerator.prepare()
}
/// ⌫를 누르고 있어 연달아 지울 때 톡톡
static func tick() {
tickGenerator.impactOccurred()
tickGenerator.prepare()
}
}
UIImpactFeedbackGenerator의 모양과 준비
UIImpactFeedbackGenerator(4편)는 만들 때 진동 모양을 고른다. .heavy는 크고 무거운 것이 부딪히는 것 같은 가장 센 진동이고, .rigid는 딱딱한 것끼리 부딪히는 것 같은 짧고 또렷한 진동이다. 연달아 울리는 톡톡은 하나하나가 뭉개지지 않게 rigid로 남겼다.
impactOccurred(intensity:)를 쓰면 같은 모양으로 세기만 0~1 사이로 바꿔 울릴 수 있다. 처음에는 톡톡을 0.4로 약하게 줬다. 1초에 열 번 넘게 울리니 손이 피곤할 거라고 봤는데, 아이폰에서는 아예 느껴지지 않았다. 그래서 세기 없이 impactOccurred()(최대 세기)로 울린다.
진동 장치는 오래 쉬면 잠들어서, 깨우는 동안 진동이 조금 늦을 수 있다. prepare()는 곧 울릴 거라고 미리 알려 장치를 깨워 둔다. 울린 직후마다 불러서 다음 누름이나 다음 톡톡이 늦지 않게 했다. 장치는 static으로 하나씩 들고 계속 쓴다.
닿는 순간과 짧은 탭
스트림덱 버튼과 키패드 키는 손가락이 닿아 있는 동안을 onPressingChanged로 알았다(4편). 여기서 닿는 순간 울리면 된다. 그런데 4편에서 겪은 대로, 아주 짧게 탭하면 이 신호가 손을 뗀 뒤에 오거나 아예 오지 않는다. 그때는 탭이 끝날 때 울려야 한다. 반대로 닿을 때 이미 울렸으면 탭이 끝날 때 또 울리면 안 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct PressHaptic {
private var lastPress = Date.distantPast
@MainActor
mutating func touched() {
KeyHaptics.press()
lastPress = .now
}
@MainActor
mutating func tapped() {
if Date.now.timeIntervalSince(lastPress) > 0.5 { KeyHaptics.press() }
lastPress = .distantPast
}
}
닿을 때 울린 시각을 적어 두고, 탭이 끝날 때 0.5초 안에 울린 적이 있으면 건너뛴다. 키는 이걸 @State로 들고 두 자리에서 부른다.
1
2
3
4
5
6
7
8
.onTapGesture {
haptic.tapped()
// 생략
}
.onLongPressGesture(minimumDuration: 10) {} onPressingChanged: { now in
pressed = now
if now { haptic.touched() }
}
스트림덱 버튼은 페이지를 옆으로 넘기는 화면 안에 있다. 이런 화면은 손가락이 닿으면 넘기려는 건지 누르려는 건지 잠깐 지켜보고 나서 버튼에 알린다. 그래서 버튼에서는 짧은 탭이 뗄 때 울리는 경우가 키패드보다 많을 수 있다. 버튼을 길게 눌러 편집을 열 때는 지금처럼 medium으로 한 번 더 울린다.
Button은 ButtonStyle로
화면 보기의 특수 키 줄, 입력칸의 “보내기”, 키패드의 “기호/123” 키는 Button이다. Button은 손가락이 닿았는지를 버튼 모양을 그리는 쪽(ButtonStyle)에 알려 준다. 그래서 진동을 버튼 모양에 넣었다.
1
2
3
4
5
6
7
8
9
struct KeyPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.6 : 1)
.onChange(of: configuration.isPressed) { _, pressed in
if pressed { KeyHaptics.press() }
}
}
}
ButtonStyle로 눌린 순간 알기
ButtonStyle은 버튼이 어떻게 보일지를 정하는 틀이다. makeBody(configuration:)에서 버튼 안에 든 그림(configuration.label)을 받아 꾸민다. isPressed는 지금 손가락이 버튼을 누르고 있는지다. 이 값이 true로 바뀌는 순간 울리고, 눌린 동안은 조금 흐리게 보이게 했다. 버튼에는 buttonStyle(_:)로 붙인다.
“보내기”는 원래 시스템 모양(.borderedProminent)이었다. 버튼 모양은 하나만 고를 수 있어서, 같은 색 배경과 흰 글자를 직접 그리고 KeyPressStyle을 붙였다. “기호/123” 키는 탭 제스처에서 Button으로 바꿨다. 맥이 예전 버전이라 흐리게 둘 때는 disabled(4편)로 막아서 눌리지도 울리지도 않는다.
⌫는 누르고 있으면 계속 지우는 키라 Button이 아니라 8편의 @GestureState로 만들었다. 누르기 시작할 때 heavy로 한 번, 0.4초 뒤부터 지울 때마다 rigid로 톡톡 울린다.
1
2
3
4
5
6
7
8
9
10
11
12
13
private func startRepeating() {
guard repeater == nil else { return }
KeyHaptics.press()
send()
repeater = Task { @MainActor in
try? await Task.sleep(for: .seconds(0.4))
while !Task.isCancelled {
KeyHaptics.tick()
send()
try? await Task.sleep(for: .seconds(0.08))
}
}
}
숫자 키패드의 ⌫는 원래 한 번 누르면 한 글자만 지워서, 여러 글자를 지우려면 그만큼 눌러야 했다. 이것도 같은 방식으로 길게 누르면 계속 지우고 톡톡 울리게 했다. 키패드는 페이지를 넘기는 화면 안이 아니라서 손가락이 닿는 순간 바로 첫 글자를 지운다.
볼륨 게이지는 키가 아니라서 그대로 뒀다. 3편에서 만든 대로 10% 눈금을 넘을 때마다 light로 짧게 울린다. 볼륨 줄의 음소거 키는 키라서 다른 키처럼 닿는 순간 울리게 바꿨다.
테스트
기호 목록과 메시지, 맥이 글자 그대로 넣는 부분은 테스트부터 썼다. 진동은 손으로 느껴야 하는 것이라 테스트 대신 실기기에서 확인한다. DeckKit 176개, 맥 62개가 통과한다.
| 무엇 | 테스트 |
|---|---|
| 기호 목록과 순서 | symbolKeysAreTheSixteenInLayoutOrder |
| 메시지 | symbolKeysRoundTrip, unknownSymbolIsRejected |
| 기능 번호 | symbolKeysNeedProtocolThree |
| 맥 입력 | symbolsAreTypedAsCharacters |

