StreamDeck (13) 버튼을 끌어 옮기는 흔들리는 옮기기 모드
이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.
버튼을 만들다 보면 자리를 바꾸고 싶어진다. 자주 쓰는 버튼은 앞으로 당기고, 새로 만든 버튼은 비슷한 것끼리 모으고 싶다. 그런데 지금은 방법이 없었다. 지우고 다른 칸에 처음부터 다시 만들어야 했다.
정한 것은 넷이다.
- 아이폰 홈 화면처럼 끌어서 옮긴다. 옮기는 동안 버튼들이 흔들린다.
- 화면 가장자리로 끌고 가면 페이지가 넘어가서 다른 페이지로도 옮긴다.
- 여러 개를 옮기다가 “완료”를 누르면 끝난다.
- 길게 눌러 버튼 설정을 바꾸는 지금 기능(4편)은 그대로 둔다.
맥이 맡는 옮기기
4편에서 정한 대로 버튼 배치의 원본은 맥에만 있다. 아이폰과 아이패드는 받아서 보여 주고, 고칠 때는 맥에 부탁한다. 지금까지 부탁은 “이 칸에 이 버튼 넣기”(setButton)와 “이 칸 비우기”(clearButton) 두 가지였다.
옮기기를 이 둘로 할 수도 있다. 새 칸에 넣고 옛 칸을 비우면 된다. 하지만 두 번에 나눠 보내면 그 사이에 연결이 끊길 수 있다. 넣기만 가고 비우기가 안 가면 버튼이 둘이 되고, 순서가 뒤집히면 버튼이 사라진다. 그래서 “옮기기”(moveButton) 메시지를 하나 새로 만들어, 맥이 한 번에 처리하게 했다.
1
2
3
4
5
6
7
8
9
10
// DeckLayout.swift (DeckKit)
public mutating func move(fromPage: Int, fromIndex: Int, toPage: Int, toIndex: Int, expecting button: DeckButton) throws {
try checkRange(page: fromPage, index: fromIndex)
try checkRange(page: toPage, index: toIndex)
guard pages[fromPage].buttons[fromIndex] == button else { throw LayoutError.stale }
if fromPage == toPage && fromIndex == toIndex { return }
pages[fromPage].buttons[fromIndex] = pages[toPage].buttons[toIndex]
pages[toPage].buttons[toIndex] = button
normalize()
}
Equatable로 집어 든 버튼이 그대로인지 보기
옮길 칸이 비어 있으면 그 칸으로 들어가고, 차 있으면 두 버튼이 자리를 바꾼다. 아랫줄 두 줄이 그 일을 한다. 옛 칸에 새 칸의 값(비어 있으면 nil)을 넣고, 새 칸에 집어 든 버튼을 넣는다. 아이폰 홈 화면처럼 뒤의 버튼들을 한 칸씩 밀지 않은 건, 이 앱은 빈 칸도 일부러 남겨 두는 배치라서다. 마지막 normalize()는 4편의 규칙(맨 끝에 빈 페이지가 하나 있게)을 다시 맞춘다.
pages[fromPage].buttons[fromIndex] == button은 두 버튼이 같은지 비교한다. DeckButton이 Equatable을 따르기 때문에 ==를 쓸 수 있다. 이름과 하는 일이 모두 같아야 같다고 본다.
두 기기가 같은 칸을 고치면
이 비교가 필요한 이유는 기기가 둘 이상일 수 있어서다. 아이폰으로 버튼을 끄는 사이에, 아이패드에서 같은 칸의 버튼을 지우거나 다른 버튼으로 바꿀 수 있다. 맥이 “3번 칸을 7번으로” 같은 번호만 받으면, 그사이 3번 칸에 들어온 엉뚱한 버튼을 옮기게 된다.
그래서 기기는 집어 든 순간의 버튼을 같이 실어 보낸다. 맥은 그 칸의 버튼이 지금도 같을 때만 옮기고, 다르면 stale(이미 낡은 부탁)로 거절한다. 거절하든 성공하든 맥은 지금 배치를 모든 기기에 다시 보낸다. 그래서 틀어진 기기도 곧 맥의 배치로 돌아온다.
이 메시지를 아는 맥 앱은 기능 번호 7부터다. 맥 앱이 예전 버전이면 아이폰은 옮기기를 시작하지 않는다. 맥이 잠겨 있을 때는 다른 편집처럼 옮기기도 받지 않는다(11편의 LockGate).
길게 누르기와 끌기 나누기
예전에는 버튼을 0.45초 누르고 있으면 진동이 오면서 바로 설정 창이 열렸다. 그런데 이제는 0.45초가 지난 뒤에 두 갈래가 있다. 그냥 떼면 설정이고, 누른 채 끌면 옮기기다. 0.45초 그 순간에는 어느 쪽인지 알 수 없다. 그래서 정하는 시점을 손을 뗄 때로 미뤘다.
진동은 그대로 0.45초에 온다. 손을 대고 있는 동안 “이제 길게 누른 거다”를 알려 주고, 결과는 손을 뗄 때 나온다.
제스처를 이어 붙이는 sequenced란?
SwiftUI에서 제스처는 탭, 길게 누르기, 끌기처럼 손가락 움직임 하나를 알아보는 도구다. 제스처끼리는 이어 붙일 수 있다. “길게 누르기가 끝나면, 그다음부터는 끌기로 본다”처럼 순서를 정하는 것이다. 이걸 sequenced(차례대로)라고 부른다.
계단을 생각하면 쉽다. 첫 계단(길게 누르기)을 다 올라가야 둘째 계단(끌기)에 발을 올릴 수 있다. 첫 계단에서 미끄러지면(0.45초 전에 손가락을 옆으로 밀면) 둘째 계단은 아예 시작하지 않는다. 그래서 짧게 밀어 페이지를 넘기는 손짓과 길게 누른 뒤 끄는 손짓이 섞이지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ButtonTile.swift (iOS)
@GestureState private var holding = false
private var pressDrag: some Gesture {
LongPressGesture(minimumDuration: quickDrag ? 0.01 : 0.45)
.sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named(DeckView.gridSpace)))
.updating($holding) { value, state, _ in
if case .second(true, _) = value { state = true }
}
.onChanged { value in
guard case .second(true, let drag?) = value else { return }
if !dragStarted, canDrag, hypot(drag.translation.width, drag.translation.height) >= 10 {
dragStarted = true
onDragChanged(drag.location, true)
} else if dragStarted {
onDragChanged(drag.location, false)
}
}
.onEnded { value in
endHandled = true
if case .second(true, let drag?) = value { finish(at: drag.location) } else { finish(at: nil) }
}
}
// body 안
.simultaneousGesture(pressDrag, including: button == nil ? .subviews : .all)
LongPressGesture와 sequenced(before:)로 길게 누른 뒤 끌기
LongPressGesture는 길게 누르기를 알아본다.init(minimumDuration:maximumDistance:)의minimumDuration이 몇 초를 눌러야 하는지다. 옮기기 모드에서는 바로 끌 수 있게 0.01초로 줄였다.maximumDistance는 기본값(10pt)을 써서, 그보다 많이 움직이면 길게 누르기가 실패한다.sequenced(before:)는 앞 제스처가 끝나야 뒤 제스처를 시작한다. 여기서는 길게 누르기 뒤에 끌기를 붙였다.DragGesture는 끌기를 알아본다.init(minimumDistance:coordinateSpace:)의minimumDistance를 0으로 둬서, 길게 누르기가 끝난 순간부터 손가락 위치를 받는다.coordinateSpace는 위치를 어느 화면 기준으로 잴지다.named(_:)로 이름 붙인 버튼판 기준으로 재야, 버튼 밖으로 끌고 나가도 판 위의 어디인지 알 수 있다.- 이어 붙인 제스처의 값은
SequenceGesture.Value다..first는 아직 첫 제스처(길게 누르는 중),.second(true, drag)는 첫 제스처가 끝나고 둘째(끌기)로 넘어간 상태다.drag는 아직 끌기 신호가 없으면 nil이다. updating(_:body:)은 제스처가 이어지는 동안만 값을 바꿔 둔다.GestureState로 만든holding은 손을 떼면 저절로 false로 돌아간다. “길게 누르기가 끝나 끌기를 기다리는 중”을 이걸로 안다.onChanged(_:)는 손가락이 움직일 때마다 불린다.DragGesture.Value의translation은 처음 위치에서 얼마나 움직였는지,location은 지금 손가락 위치다. 움직인 거리가 10pt를 넘는 순간부터 옮기기로 본다. 그보다 적게 흔들린 건 손가락 떨림으로 본다.onEnded(_:)는 손을 뗄 때 불린다. 끌었으면 놓을 칸을 정하고, 안 끌었으면 설정 창을 연다(finish).simultaneousGesture(_:including:)는 이 제스처를 이미 있는 탭, 길게 누르기와 같이 돌린다. 둘 중 하나만 고르지 않고 둘 다 알아보게 하는 것이다.including에는GestureMask를 넣는다. 빈 칸은 옮길 게 없으니.subviews로 이 제스처를 끄고, 버튼일 때만.all로 켰다.
quickDrag는 옮기기 모드인지를 따르는데, 손을 뗐을 때만 바꾼다. 끄는 도중 옮기기 모드가 켜지면서 제스처 설정(0.45초가 0.01초로)이 바뀌면, 하던 손짓이 끊길 수 있어서다.
손을 뗐는데 onEnded가 불리지 않는 경우가 있는지는 확실하지 않았다. 그래서 holding이 false로 돌아갔는데 0.05초 안에 끝 신호가 없으면, 끝난 것으로 보고 정리하게 대비해 뒀다.
진동 없는 아이패드를 위한 떠오름과 말풍선
아이폰은 0.45초에 진동이 와서 길게 누른 게 됐는지 안다. 아이패드는 진동이 없어서, 그 순간을 알 수 없었다. 손을 떼도 되는지, 더 기다려야 하는지 모른다.
그래서 0.45초가 되면 버튼이 1.06배로 살짝 커지면서 그림자가 생긴다. 들려서 떠오른 모습이다. 이것만으로는 “움직일 수 있다”는 알 수 있어도 “그냥 떼면 설정이 열린다”는 알 수 없다. 그래서 떠오른 버튼 위에 “놓으면 설정, 끌면 옮기기” 말풍선을 같이 띄웠다. 아이폰에도 똑같이 넣었다.
아이패드에서 Finder 버튼을 길게 누른 화면이다. 버튼이 살짝 커져 있고, 위에 말풍선이 떠서 꼬리가 Finder를 가리킨다.
말풍선은 보통 버튼 위에 뜬다. 맨 윗줄 버튼이면 위에 자리가 없어서 아래에 띄운다. 왼쪽 끝이나 오른쪽 끝 버튼이면 말풍선을 안쪽으로 당겨서 화면 밖으로 잘리지 않게 하고, 꼬리만 버튼 쪽으로 옮긴다. 끌기 시작하면 말풍선은 바로 사라진다. 그때는 버튼이 손가락을 따라오니 글이 필요 없다. 맥 앱이 예전 버전이라 옮길 수 없으면 “놓으면 설정”만 띄운다.
말풍선을 버튼 안에 그리지 않고 버튼판 위의 겹친 층에 그렸다. 버튼 안에 그리면 옆 줄의 버튼이 나중에 그려지면서 말풍선을 덮을 수 있다. 그래서 칸의 자리를 계산해 그 위에 띄운다. 칸 자리 계산은 아래 “손가락 아래 칸 찾기”와 같은 식이라 DeckKit에 두고 테스트했다.
흔들리는 옮기기 모드
옮기기 모드가 되면 버튼들이 좌우로 살짝 기울며 흔들린다. 상단의 화면 고르기(스트림덱, 숫자 키패드, 맥 화면) 자리에는 “완료” 버튼이 대신 뜬다. 옮기기 모드에서는 버튼을 탭해도 맥에서 실행하지 않고 설정 창을 연다. 자리를 옮기려다 실수로 명령이 돌면 안 되기 때문이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ButtonTile.swift (iOS)
/// 옮기기 모드에서 iOS 홈 화면처럼 좌우로 살짝 기울며 흔들린다. 왕복 0.25초, ±1.5도
private struct Wiggle: ViewModifier {
let on: Bool
let phase: Double
func body(content: Content) -> some View {
if on {
TimelineView(.animation) { context in
let t = context.date.timeIntervalSinceReferenceDate
content.rotationEffect(.degrees(1.5 * sin((t / 0.25 + phase) * 2 * .pi)))
}
} else {
content
}
}
}
// body 안
.modifier(Wiggle(on: arranging && button != nil && !lifted && !reduceMotion, phase: phase))
TimelineView와 rotationEffect로 흔들기
ViewModifier는 화면 조각에 덧붙일 꾸밈을 하나로 묶은 것이다.body(content:)가 받은 화면(content)을 꾸며 돌려준다.modifier(_:)로 붙인다.TimelineView는 정해진 때마다 안을 다시 그린다..animation을 주면 화면이 새로 그려지는 박자에 맞춰 그린다.context.date가 지금 시각이다.rotationEffect(_:anchor:)는 화면 조각을 돌린다. 지금 시각을 사인 함수에 넣어 -1.5도와 1.5도 사이를 0.25초마다 오가게 했다. 사인 함수는 시간이 가면 -1과 1 사이를 부드럽게 오가는 값이라, 흔들림이 끊기지 않는다.
phase는 칸 번호마다 다르게 준 출발 시점이다. 모두 같은 박자로 흔들리면 판 전체가 한 덩어리로 기우는 것처럼 보여서, 조금씩 어긋나게 했다. 집어 든 버튼과 빈 칸은 흔들지 않는다.
흔드는 방법은 두 가지를 놓고 골랐다. 하나는 “계속 되풀이하는 애니메이션”을 켜 두는 것이고, 다른 하나는 지금처럼 매 순간 각도를 계산하는 것이다. 되풀이 애니메이션은 “완료”를 눌렀을 때 깔끔하게 멈추지 않는 일이 잦다. 시각으로 각도를 계산하면 on이 false가 되는 순간 TimelineView가 빠지고 바로 멈춘다.
동작 줄이기란?
아이폰과 아이패드 설정의 손쉬운 사용에는 “동작 줄이기”가 있다. 화면이 흔들리거나 확대되는 움직임에 어지러움을 느끼는 사람을 위해, 앱들이 움직임을 줄이라고 알리는 스위치다. 이걸 켜 둔 사람에게 버튼 열두 개가 계속 흔들리는 건 괴롭다.
SwiftUI에서는 accessibilityReduceMotion으로 이 스위치를 읽는다. @Environment(\.accessibilityReduceMotion)으로 받아 두고, 켜져 있으면 흔드는 대신 버튼 테두리를 밝게 바꿔 옮기기 모드임을 알렸다.
옮기기 모드가 끝나는 때
“완료”를 누르면 끝난다. 그 밖에도 세 경우에 저절로 끝난다. 맥이 잠기거나 연결이 끊겨 버튼을 못 쓰게 됐을 때, 그리고 앱이 뒤로 갔을 때다. 앱이 뒤로 간 것은 scenePhase로 안다. 이 값은 앱 화면이 지금 앞에 떠 있는지(active), 잠깐 가려졌는지, 뒤로 갔는지를 알려 준다. onChange(of:initial:_:)로 값이 바뀔 때를 받아 active가 아니면 끝냈다.
끄는 도중에 끝나면(맥이 잠기면) 손가락을 계속 움직여도 다시 집어 들지 않는다. 처음 만들었을 때는 이 경우에 다음 움직임 신호가 옮기기를 처음부터 다시 시작했다. 그래서 버튼이 “이번 끌기의 첫 신호”인지를 같이 알려 주고, 화면은 첫 신호일 때만 집어 들게 고쳤다.
TabView 대신 직접 그린 페이지
페이지는 4편부터 TabView의 페이지 모양으로 옆으로 밀어 넘겼다. 옮기기를 넣으면서 이걸 직접 그린 페이지로 바꿨다. 까닭은 둘이다.
- 끄는 중에 가장자리에서 페이지가 넘어가야 한다.
TabView는 안 보이는 페이지를 치워 둘 수 있어서, 넘어가는 순간 내가 끌던 버튼의 화면 조각이 사라지면 손짓도 같이 끊긴다. TabView의 옆으로 밀기와 버튼을 끄는 손짓이 서로 손가락을 차지하려고 다툰다.
직접 그리면 페이지를 전부 옆으로 늘어놓고 지금 페이지만 보이게 밀어 둔다. 모든 페이지가 계속 살아 있어서 넘어가도 손짓이 끊기지 않는다. 밀기도 우리가 만든 제스처라, 버튼을 끄는 중에는 밀기를 무시하게 할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// DeckView.swift (iOS)
GeometryReader { geo in
let size = geo.size
HStack(spacing: Self.pageGap) {
ForEach(Array(client.layout.pages.enumerated()), id: \.offset) { pageIndex, deckPage in
grid(deckPage, page: pageIndex, columns: columns, size: size)
.frame(width: size.width, height: size.height)
}
}
.offset(x: -CGFloat(page) * (size.width + Self.pageGap) + swipe)
.frame(width: size.width, height: size.height, alignment: .leading)
.animation(.spring(duration: 0.3), value: page)
.overlay { liftedTile(columns: columns, size: size) }
.overlay { holdHint(columns: columns, size: size) }
.gesture(pageSwipe(width: size.width))
// 손가락이 가장자리에 가만히 있어도 시간이 가야 페이지가 넘어간다
.task(id: lift == nil) {
while !Task.isCancelled, let current = lift {
flipIfAtEdge(x: current.location.x, width: size.width)
try? await Task.sleep(for: .milliseconds(100))
}
}
}
.coordinateSpace(.named(Self.gridSpace))
.mask { Rectangle().padding(-Self.pageGap / 2) }
HStack과 offset, mask로 페이지 그리기
GeometryReader로 보이는 칸의 크기(size)를 잰다. 페이지 하나가 이 크기다.HStack으로 페이지를 옆으로 늘어놓는다.spacing에 페이지 사이 틈(32pt)을 줬다.offset(x:y:)으로 줄 전체를 왼쪽으로 민다. 페이지 번호에 (페이지 너비 + 틈)을 곱한 만큼 밀면 그 페이지가 보이는 칸에 온다.swipe는 손으로 밀고 있는 거리라, 미는 동안 따라 움직인다.frame(width:height:alignment:)으로 바깥 크기를 페이지 하나로 정하고 왼쪽에 붙였다. 줄 전체는 이보다 넓지만, 자리는 페이지 하나만큼만 차지한다..animation(.spring(duration: 0.3), value: page)는 페이지 번호가 바뀔 때 미는 움직임을 스프링처럼 부드럽게 한다.coordinateSpace(_:)로 보이는 칸에 이름을 붙인다. 위 끌기 제스처가 이 이름으로 손가락 위치를 잰다.task(id:name:priority:file:line:_:)는id가 바뀔 때마다 비동기 작업을 새로 시작한다. 버튼을 집어 들면lift == nil이 false로 바뀌어 작업이 시작되고, 놓으면 다시 바뀌어 작업이 취소된다. 그 사이 0.1초마다 가장자리에 머물렀는지 본다. 손가락이 가만히 있으면 움직임 신호가 오지 않아서, 이렇게 따로 시간을 재야 한다.mask(alignment:_:)는 준 모양만큼만 보이게 하고 나머지를 가린다. 아래에서 따로 설명한다.
손으로 미는 제스처는 버튼을 끄는 중이면 아무것도 하지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// DeckView.swift (iOS)
@GestureState(resetTransaction: Transaction(animation: .spring(duration: 0.3))) private var swipe: CGFloat = 0
private func pageSwipe(width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 15)
.updating($swipe) { value, state, _ in
guard lift == nil else { return }
let dx = value.translation.width
// 첫 페이지 왼쪽, 마지막 페이지 오른쪽은 조금만 끌려 온다
let atEnd = (page == 0 && dx > 0) || (page == client.layout.pages.count - 1 && dx < 0)
state = atEnd ? dx / 3 : dx
}
.onEnded { value in
guard lift == nil else { return }
let dx = value.predictedEndTranslation.width
if dx < -width / 3 { page = min(page + 1, client.layout.pages.count - 1) }
if dx > width / 3 { page = max(page - 1, 0) }
}
}
GestureState와 predictedEndTranslation으로 페이지 밀기
init(wrappedValue:resetTransaction:)은 손을 뗐을 때 값이 처음 값(0)으로 돌아가는 움직임을 정한다. Transaction은 화면 변화에 붙는 애니메이션 정보다. 페이지를 넘기지 못하고 놓았을 때 줄이 툭 튀지 않고 스프링처럼 제자리로 돌아온다.
predictedEndTranslation은 지금 속도로 계속 갔다면 어디서 멈췄을지 예측한 거리다. 짧게 휙 밀어도 예측 거리가 페이지 너비의 3분의 1을 넘으면 넘어간다. 실제로 움직인 거리만 보면 휙 미는 손짓이 잘 안 먹는다.
minimumDistance를 15pt로 둔 건 버튼의 길게 누르기 허용 거리(10pt)보다 크게 하려는 것이다. 버튼 위에서 밀면 먼저 길게 누르기가 10pt에서 실패하고, 15pt에서 페이지 밀기가 시작된다. 옮기기 모드에서는 버튼이 거의 바로 끌리기 시작해서, 버튼 위에서 밀면 그 버튼이 끌린다. 이때 페이지는 빈 칸 위를 밀어 넘긴다.
잘리던 가장자리 버튼
처음에는 clipped(antialiased:)로 보이는 칸 바깥을 잘라 냈다. 옆 페이지가 비쳐 보이면 안 되기 때문이다. 그런데 실기기에서 맨 오른쪽 칸 버튼을 길게 눌렀더니, 떠오르며 커진 버튼의 오른쪽 끝이 잘려 있었다. 커진 만큼이 보이는 칸 밖으로 나갔기 때문이다. 끌어서 집어 든 버튼(1.1배)도 같다.
그래서 잘라 내는 창을 페이지보다 양쪽으로 16pt 넓혔다. mask에 준 사각형에 padding(-16)을 줘서 밖으로 키운 것이다. 넓힌 만큼 옆 페이지가 비치지 않게 페이지 사이에는 32pt 틈을 뒀다. 창은 틈의 절반까지만 오니 옆 페이지는 여전히 가려진다. 버튼판 바깥에는 원래 화면 여백 20pt가 있어서, 넓힌 16pt가 화면 끝이나 아이패드의 볼륨 줄을 덮지 않는다.
손가락 아래 칸 찾기와 가장자리 머물기
놓을 칸을 정하려면 손가락이 몇 번째 칸 위에 있는지 알아야 한다. 칸은 아이폰이 3열 4줄, 아이패드가 4열 3줄이고 칸 사이 틈은 14pt다. 판 크기를 알면 칸 크기가 나온다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ArrangeGeometry.swift (DeckKit)
public static func index(at point: CGPoint, size: CGSize, columns: Int, spacing: CGFloat) -> Int? {
let rows = DeckConstants.slotsPerPage / columns
let cellWidth = (size.width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
let cellHeight = (size.height - spacing * CGFloat(rows - 1)) / CGFloat(rows)
guard point.x >= 0, point.y >= 0, point.x <= size.width, point.y <= size.height else { return nil }
guard let column = cell(point.x, length: cellWidth, spacing: spacing, count: columns),
let row = cell(point.y, length: cellHeight, spacing: spacing, count: rows) else { return nil }
return row * columns + column
}
private static func cell(_ value: CGFloat, length: CGFloat, spacing: CGFloat, count: Int) -> Int? {
let stride = length + spacing
let n = Int((value / stride).rounded(.down))
guard n < count, value - CGFloat(n) * stride <= length else { return nil }
return n
}
rounded(.down)으로 몇 번째 칸인지 세기
CGPoint(점), CGSize(크기), CGFloat(화면 길이 숫자)은 화면 좌표의 기본 타입이다. 칸 하나와 틈 하나를 합친 길이(stride)로 손가락 위치를 나누면 몇 번째 칸쯤인지 나온다. rounded(_:)에 .down을 주면 소수점 아래를 버린다. 2.7이면 2번째(0부터 세서) 칸이다. 그다음 그 칸의 시작에서 얼마나 들어왔는지 보고, 칸 너비보다 크면 틈에 있는 것이라 nil을 돌려준다. 가로와 세로를 따로 세서 줄 * 열 수 + 열이 칸 번호다.
틈이나 판 밖에서 손을 떼면 아무 칸도 아니라서 버튼은 원래 자리로 돌아간다. 틈에 놓았을 때 가까운 칸으로 붙이는 방법도 있지만, 어디 놓일지 테두리로 보여 주고 있으니 테두리가 없는 곳에서는 아무 일도 없는 게 헷갈리지 않는다고 봤다.
가장자리 머물기(EdgePager)도 같은 DeckKit에 순수 계산으로 뒀다. 손가락이 보이는 칸의 왼쪽이나 오른쪽 40pt 안에 들어오면 시각을 적어 두고, 0.5초가 지나면 그쪽으로 한 페이지 넘긴다. 계속 머물면 0.7초마다 한 번 더 넘긴다. 가장자리를 벗어나면 시각을 지우고, 첫 페이지 왼쪽과 마지막 페이지 오른쪽에서는 넘기지 않는다. 처음을 조금 짧게 둔 건, 일부러 가장자리로 간 거라면 금방 넘어가길 바라고, 이미 넘어간 뒤에는 둘러볼 틈을 주려는 것이다.
놓는 순간 먼저 바뀌는 화면
버튼을 놓으면 맥에 “옮기기”를 보내고, 맥이 바뀐 배치를 돌려보낸다. 그 답을 기다렸다가 화면을 바꾸면, 놓은 뒤 잠깐 버튼이 원래 자리로 돌아갔다가 옮겨지는 것처럼 보인다. 밖에서 Tailscale로 붙어 있으면 그 잠깐이 더 길다.
그래서 아이폰은 놓는 순간 자기 화면의 배치를 먼저 바꾸고 맥에 보낸다. 맥의 답이 오면 그 배치로 덮는다. 대부분은 같은 배치라 아무 변화가 없다. 맥이 거절했으면(다른 기기가 그사이 바꿨거나 맥이 잠겼으면) 맥의 배치로 돌아온다. 식당에서 주문을 넣자마자 내 메모에 먼저 적어 두고, 주방에서 “그 메뉴 다 떨어졌어요”가 오면 메모를 고치는 것과 비슷하다.
1
2
3
4
5
6
7
8
9
10
11
// DeckClient.swift (iOS)
func moveButton(fromPage: Int, fromIndex: Int, toPage: Int, toIndex: Int) {
guard fromPage != toPage || fromIndex != toIndex,
layout.pages.indices.contains(fromPage),
layout.pages[fromPage].buttons.indices.contains(fromIndex),
let button = layout.pages[fromPage].buttons[fromIndex] else { return }
var next = layout
guard (try? next.move(fromPage: fromPage, fromIndex: fromIndex, toPage: toPage, toIndex: toIndex, expecting: button)) != nil else { return }
layout = next
send(.moveButton(fromPage: fromPage, fromIndex: fromIndex, toPage: toPage, toIndex: toIndex, button: button))
}
아이폰도 맥과 똑같은 move로 미리 바꾼다. 같은 함수라 아이폰이 미리 그린 결과와 맥의 결과가 다를 일이 없다.
맥이 잠겨 있어서 옮기기를 거절할 때는 원래 아무 답도 보내지 않았다(11편의 잠긴 동안 막기). 그러면 아이폰이 미리 바꾼 화면이 그대로 남는다. 그래서 잠겨서 거절한 메시지가 옮기기면, 그 기기에 지금 배치를 다시 보내게 했다.
테스트
배치 바꾸기, 메시지, 칸 찾기, 가장자리 머물기는 DeckKit에 두고 테스트부터 썼다. DeckKit 229개, 맥 71개가 통과한다. 손짓과 흔들림, 말풍선은 아이폰 앱 쪽이라 자동 테스트가 없고, 빌드를 확인한 뒤 실기기에서 점검표대로 확인했다.
| 무엇 | 테스트 |
|---|---|
| 옮기기 | moveIntoEmptySlot, moveOntoFilledSlotSwaps, moveToSameSlotChangesNothing, moveAcrossPages |
| 페이지 수 | moveToTrailingEmptyPageAddsPage, moveOffLastFilledPageTrimsTrailing, moveEmptyingMiddlePageKeepsIt |
| 낡은 부탁 | moveRejectsStaleButton |
| 범위 밖 | moveOutOfRangeThrows |
| 메시지와 기능 번호 | moveButtonRoundTrip, moveButtonNeedsProtocolSeven |
| 잠긴 동안 | lockedMacRefusesActions |
| 칸 찾기 | slotFromPointThreeColumns, slotFromPointFourColumns, gapIsNoSlot, outsideIsNoSlot, cellFrameMatchesSlotLookup |
| 가장자리 머물기 | pagerWaitsHalfSecondThenRepeats, pagerLeftEdgeGoesBack, pagerStopsAtEnds, leavingEdgeResetsTimer |
가장자리 머물기 테스트의 시각 값은 0.5초나 0.7초에 딱 맞추지 않고 1.21, 0.91처럼 살짝 넘겼다. 컴퓨터의 소수는 1.2 - 0.5가 정확히 0.7이 되지 않을 수 있어서, 경계에 딱 맞춘 테스트는 계산 오차로 결과가 흔들릴 수 있다.



