oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2
3// 0.4.0: the tiles of Grid and of Speaker view's filmstrip can be put in
4// the viewer's own order by dragging one onto another's place (a long
5// press first on a touch screen), and pinned to the stage. Every drag has
6// a way round it: the tile's context menu and its accessibility actions
7// say Pin or Unpin, Move earlier, Move later and Move to first.
8//
9// The dragged tile lifts — a little larger, with a shadow and an accent
10// edge — and follows the pointer; the others make room for it as it
11// passes over their places, so where it will land is always where the gap
12// is. With Reduce Motion nothing grows or springs: the tile still follows
13// the pointer and the others change places without easing.
14
15/// A tile being dragged: whose, where it was picked up (the pointer's
16/// offset from the tile's corner) and where the pointer is now, in the
17/// arrangement's coordinate space.
18public struct TileDrag: Equatable {
19 var peer: String
20 var grab: CGSize
21 var pointer: CGPoint
22}
23
24/// Where each tile is, in the arrangement's coordinate space, by key: a
25/// person's peer id, or "screen:" and theirs for a screen.
26public struct TileFramesKey: PreferenceKey {
27 public static let defaultValue: [String: CGRect] = [:]
28 public static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
29 value.merge(nextValue(), uniquingKeysWith: { _, new in new })
30 }
31}
32
33extension StageItem {
34 /// The tile's key among the frames.
35 var tileKey: String {
36 switch self {
37 case .camera(let peer): peer
38 case .screen(let peer): "screen:" + peer
39 }
40 }
41}
42
43/// The coordinate space the tiles of one arrangement are measured in.
44public let arrangeSpace = "arrange"
45
46/// What one tile of Grid or the filmstrip can do: be picked up and moved
47/// among the others, be pinned, and say both to VoiceOver.
48public struct ArrangeableTile: ViewModifier {
49 let item: StageItem
50 /// The name the menus use: "Ana García", "You", "Ana García’s screen".
51 let title: String
52 @ObservedObject var arrangement: CallArrangement
53 @Binding var drag: TileDrag?
54 let frames: [String: CGRect]
55 /// A click or tap pins it (the filmstrip); in Grid it does not.
56 let tapToPin: Bool
57 /// Touch: a long press picks it up. Pointer: a drag of a few points.
58 let touch: Bool
59 let reduceMotion: Bool
60 /// A fixture holding this tile lifted, for the snapshots.
61 var fixtureLift: CGSize? = nil
62
63 public init(
64 item: StageItem, title: String, arrangement: CallArrangement, drag: Binding<TileDrag?>,
65 frames: [String: CGRect], tapToPin: Bool, touch: Bool, reduceMotion: Bool,
66 fixtureLift: CGSize? = nil
67 ) {
68 self.item = item
69 self.title = title
70 self.arrangement = arrangement
71 _drag = drag
72 self.frames = frames
73 self.tapToPin = tapToPin
74 self.touch = touch
75 self.reduceMotion = reduceMotion
76 self.fixtureLift = fixtureLift
77 }
78
79 private var peer: String? {
80 if case .camera(let peer) = item { return peer }
81 return nil
82 }
83 private var lifted: Bool { (peer != nil && drag?.peer == peer) || fixtureLift != nil }
84
85 public func body(content: Content) -> some View {
86 let pinned = arrangement.pinned == item
87 content
88 .background(
89 GeometryReader { proxy in
90 Color.clear.preference(
91 key: TileFramesKey.self, value: [item.tileKey: proxy.frame(in: .named(arrangeSpace))])
92 }
93 )
94 .overlay {
95 if lifted {
96 RoundedRectangle(cornerRadius: 8).strokeBorder(CallsAppearance.accentLit, lineWidth: 3)
97 .allowsHitTesting(false)
98 }
99 }
100 .scaleEffect(lifted && !reduceMotion ? 1.06 : 1)
101 .shadow(color: .black.opacity(lifted ? 0.5 : 0), radius: lifted ? 16 : 0, y: lifted ? 8 : 0)
102 .offset(offset)
103 .zIndex(lifted ? 1 : 0)
104 .contentShape(Rectangle())
105 // In the filmstrip a click or tap pins, once it is plainly not the
106 // start of a drag; in Grid a click on a tile is a click on the
107 // picture, which shows the controls (a tap toggles them), so there
108 // the tile takes only the drag.
109 // In the filmstrip a click or tap pins; in Grid a click on a tile
110 // is a click on the picture, which shows the controls (a tap
111 // toggles them), so there the tile takes only the drag. The masks
112 // are `.subviews`, never `.none`, which would switch off every
113 // gesture inside as well.
114 .gesture(
115 TapGesture().onEnded { withAnimation(animation) { arrangement.togglePin(item) } },
116 including: tapToPin ? .all : .subviews)
117 .gesture(pickUp, including: peer != nil ? .all : .subviews)
118 #if os(macOS)
119 // On a touch screen a long press is the drag; there the menu's
120 // choices are the accessibility actions below, and Pin is a tap.
121 .contextMenu { menu(pinned: pinned) }
122 #endif
123 .accessibilityAction(named: pinned ? StageWords.unpin(title) : StageWords.pin(title)) {
124 arrangement.togglePin(item)
125 }
126 .modifier(MoveActions(peer: peer, arrangement: arrangement))
127 }
128
129 @ViewBuilder private func menu(pinned: Bool) -> some View {
130 Button(pinned ? "Unpin" : "Pin to the stage", systemImage: pinned ? "pin.slash" : "pin") {
131 withAnimation(animation) { arrangement.togglePin(item) }
132 }
133 if let peer {
134 let can = arrangement.canMove(peer)
135 Divider()
136 Button(StageWords.moveToFirst, systemImage: "arrow.up.to.line") {
137 withAnimation(animation) { _ = arrangement.moveToFirst(peer) }
138 }.disabled(!can.earlier)
139 Button(StageWords.moveEarlier, systemImage: "arrow.backward") {
140 withAnimation(animation) { _ = arrangement.moveEarlier(peer) }
141 }.disabled(!can.earlier)
142 Button(StageWords.moveLater, systemImage: "arrow.forward") {
143 withAnimation(animation) { _ = arrangement.moveLater(peer) }
144 }.disabled(!can.later)
145 }
146 }
147
148 private var animation: Animation? { reduceMotion ? nil : .spring(duration: 0.3) }
149
150 /// Where the lifted tile is drawn: under the pointer, wherever its slot
151 /// has moved to meanwhile.
152 private var offset: CGSize {
153 if let fixtureLift { return fixtureLift }
154 guard let drag, let peer, drag.peer == peer, let slot = frames[peer] else { return .zero }
155 return CGSize(
156 width: drag.pointer.x - drag.grab.width - slot.minX,
157 height: drag.pointer.y - drag.grab.height - slot.minY)
158 }
159
160 private var pickUp: some Gesture {
161 #if os(iOS)
162 LongPressGesture(minimumDuration: 0.35)
163 .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named(arrangeSpace)))
164 .onChanged { value in
165 if case .second(true, let drag?) = value { moved(drag) }
166 }
167 .onEnded { _ in dropped() }
168 #else
169 DragGesture(minimumDistance: 4, coordinateSpace: .named(arrangeSpace))
170 .onChanged(moved)
171 .onEnded { _ in dropped() }
172 #endif
173 }
174
175 private func moved(_ value: DragGesture.Value) {
176 guard let peer else { return }
177 if drag?.peer != peer {
178 let slot = frames[peer] ?? .zero
179 drag = TileDrag(
180 peer: peer,
181 grab: CGSize(width: value.startLocation.x - slot.minX, height: value.startLocation.y - slot.minY),
182 pointer: value.location)
183 #if os(iOS)
184 UIImpactFeedbackGenerator(style: .medium).impactOccurred()
185 #endif
186 } else {
187 drag?.pointer = value.location
188 }
189 // Over another person's place: take it, and they make room.
190 let people = arrangement.people
191 guard
192 let target = frames.first(where: { key, frame in
193 key != peer && people.contains(key) && frame.contains(value.location)
194 })?.key,
195 let index = people.firstIndex(of: target)
196 else { return }
197 withAnimation(animation) { _ = arrangement.move(peer, to: index) }
198 }
199
200 private func dropped() {
201 guard let peer, drag?.peer == peer else { return }
202 withAnimation(animation) { drag = nil }
203 if let at = arrangement.people.firstIndex(of: peer) {
204 AudioAnnouncer.say(StageWords.moved(title, to: at, of: arrangement.people.count))
205 }
206 }
207}
208
209/// Move earlier, Move later and Move to first, as accessibility actions,
210/// on a person's tile (a screen has none).
211private struct MoveActions: ViewModifier {
212 let peer: String?
213 @ObservedObject var arrangement: CallArrangement
214
215 func body(content: Content) -> some View {
216 if let peer {
217 content
218 .accessibilityAction(named: StageWords.moveEarlier) { arrangement.moveEarlier(peer) }
219 .accessibilityAction(named: StageWords.moveLater) { arrangement.moveLater(peer) }
220 .accessibilityAction(named: StageWords.moveToFirst) { arrangement.moveToFirst(peer) }
221 } else {
222 content
223 }
224 }
225}
226
227/// The pin on a pinned stage: says it is pinned, and unpins when pressed.
228public struct PinBadge: View {
229 let title: String
230 let unpin: () -> Void
231 public init(title: String, unpin: @escaping () -> Void) {
232 self.title = title
233 self.unpin = unpin
234 }
235
236 public var body: some View {
237 Button(action: unpin) {
238 Label("Pinned", systemImage: "pin.fill")
239 .font(.callout.weight(.semibold)).foregroundStyle(.white)
240 .padding(.horizontal, 10).padding(.vertical, 6)
241 .callOverlaySurface(Capsule())
242 }
243 .buttonStyle(.plain)
244 .help("Unpin \(title): the stage follows whoever is speaking")
245 .accessibilityLabel(StageWords.unpin(title))
246 .accessibilityHint("The stage follows whoever is speaking")
247 .accessibilityIdentifier("unpin")
248 }
249}
250