| 1 | import 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. |
| 18 | public 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. |
| 26 | public 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 | |
| 33 | extension 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. |
| 44 | public 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. |
| 48 | public 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). |
| 211 | private 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. |
| 228 | public 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 | |