import SwiftUI // 0.4.0: the tiles of Grid and of Speaker view's filmstrip can be put in // the viewer's own order by dragging one onto another's place (a long // press first on a touch screen), and pinned to the stage. Every drag has // a way round it: the tile's context menu and its accessibility actions // say Pin or Unpin, Move earlier, Move later and Move to first. // // The dragged tile lifts — a little larger, with a shadow and an accent // edge — and follows the pointer; the others make room for it as it // passes over their places, so where it will land is always where the gap // is. With Reduce Motion nothing grows or springs: the tile still follows // the pointer and the others change places without easing. /// A tile being dragged: whose, where it was picked up (the pointer's /// offset from the tile's corner) and where the pointer is now, in the /// arrangement's coordinate space. public struct TileDrag: Equatable { var peer: String var grab: CGSize var pointer: CGPoint } /// Where each tile is, in the arrangement's coordinate space, by key: a /// person's peer id, or "screen:" and theirs for a screen. public struct TileFramesKey: PreferenceKey { public static let defaultValue: [String: CGRect] = [:] public static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { value.merge(nextValue(), uniquingKeysWith: { _, new in new }) } } extension StageItem { /// The tile's key among the frames. var tileKey: String { switch self { case .camera(let peer): peer case .screen(let peer): "screen:" + peer } } } /// The coordinate space the tiles of one arrangement are measured in. public let arrangeSpace = "arrange" /// What one tile of Grid or the filmstrip can do: be picked up and moved /// among the others, be pinned, and say both to VoiceOver. public struct ArrangeableTile: ViewModifier { let item: StageItem /// The name the menus use: "Ana García", "You", "Ana García’s screen". let title: String @ObservedObject var arrangement: CallArrangement @Binding var drag: TileDrag? let frames: [String: CGRect] /// A click or tap pins it (the filmstrip); in Grid it does not. let tapToPin: Bool /// Touch: a long press picks it up. Pointer: a drag of a few points. let touch: Bool let reduceMotion: Bool /// A fixture holding this tile lifted, for the snapshots. var fixtureLift: CGSize? = nil public init( item: StageItem, title: String, arrangement: CallArrangement, drag: Binding, frames: [String: CGRect], tapToPin: Bool, touch: Bool, reduceMotion: Bool, fixtureLift: CGSize? = nil ) { self.item = item self.title = title self.arrangement = arrangement _drag = drag self.frames = frames self.tapToPin = tapToPin self.touch = touch self.reduceMotion = reduceMotion self.fixtureLift = fixtureLift } private var peer: String? { if case .camera(let peer) = item { return peer } return nil } private var lifted: Bool { (peer != nil && drag?.peer == peer) || fixtureLift != nil } public func body(content: Content) -> some View { let pinned = arrangement.pinned == item content .background( GeometryReader { proxy in Color.clear.preference( key: TileFramesKey.self, value: [item.tileKey: proxy.frame(in: .named(arrangeSpace))]) } ) .overlay { if lifted { RoundedRectangle(cornerRadius: 8).strokeBorder(CallsAppearance.accentLit, lineWidth: 3) .allowsHitTesting(false) } } .scaleEffect(lifted && !reduceMotion ? 1.06 : 1) .shadow(color: .black.opacity(lifted ? 0.5 : 0), radius: lifted ? 16 : 0, y: lifted ? 8 : 0) .offset(offset) .zIndex(lifted ? 1 : 0) .contentShape(Rectangle()) // In the filmstrip a click or tap pins, once it is plainly not the // start of a drag; in Grid a click on a tile is a click on the // picture, which shows the controls (a tap toggles them), so there // the tile takes only the drag. // In the filmstrip a click or tap pins; in Grid a click on a tile // is a click on the picture, which shows the controls (a tap // toggles them), so there the tile takes only the drag. The masks // are `.subviews`, never `.none`, which would switch off every // gesture inside as well. .gesture( TapGesture().onEnded { withAnimation(animation) { arrangement.togglePin(item) } }, including: tapToPin ? .all : .subviews) .gesture(pickUp, including: peer != nil ? .all : .subviews) #if os(macOS) // On a touch screen a long press is the drag; there the menu's // choices are the accessibility actions below, and Pin is a tap. .contextMenu { menu(pinned: pinned) } #endif .accessibilityAction(named: pinned ? StageWords.unpin(title) : StageWords.pin(title)) { arrangement.togglePin(item) } .modifier(MoveActions(peer: peer, arrangement: arrangement)) } @ViewBuilder private func menu(pinned: Bool) -> some View { Button(pinned ? "Unpin" : "Pin to the stage", systemImage: pinned ? "pin.slash" : "pin") { withAnimation(animation) { arrangement.togglePin(item) } } if let peer { let can = arrangement.canMove(peer) Divider() Button(StageWords.moveToFirst, systemImage: "arrow.up.to.line") { withAnimation(animation) { _ = arrangement.moveToFirst(peer) } }.disabled(!can.earlier) Button(StageWords.moveEarlier, systemImage: "arrow.backward") { withAnimation(animation) { _ = arrangement.moveEarlier(peer) } }.disabled(!can.earlier) Button(StageWords.moveLater, systemImage: "arrow.forward") { withAnimation(animation) { _ = arrangement.moveLater(peer) } }.disabled(!can.later) } } private var animation: Animation? { reduceMotion ? nil : .spring(duration: 0.3) } /// Where the lifted tile is drawn: under the pointer, wherever its slot /// has moved to meanwhile. private var offset: CGSize { if let fixtureLift { return fixtureLift } guard let drag, let peer, drag.peer == peer, let slot = frames[peer] else { return .zero } return CGSize( width: drag.pointer.x - drag.grab.width - slot.minX, height: drag.pointer.y - drag.grab.height - slot.minY) } private var pickUp: some Gesture { #if os(iOS) LongPressGesture(minimumDuration: 0.35) .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named(arrangeSpace))) .onChanged { value in if case .second(true, let drag?) = value { moved(drag) } } .onEnded { _ in dropped() } #else DragGesture(minimumDistance: 4, coordinateSpace: .named(arrangeSpace)) .onChanged(moved) .onEnded { _ in dropped() } #endif } private func moved(_ value: DragGesture.Value) { guard let peer else { return } if drag?.peer != peer { let slot = frames[peer] ?? .zero drag = TileDrag( peer: peer, grab: CGSize(width: value.startLocation.x - slot.minX, height: value.startLocation.y - slot.minY), pointer: value.location) #if os(iOS) UIImpactFeedbackGenerator(style: .medium).impactOccurred() #endif } else { drag?.pointer = value.location } // Over another person's place: take it, and they make room. let people = arrangement.people guard let target = frames.first(where: { key, frame in key != peer && people.contains(key) && frame.contains(value.location) })?.key, let index = people.firstIndex(of: target) else { return } withAnimation(animation) { _ = arrangement.move(peer, to: index) } } private func dropped() { guard let peer, drag?.peer == peer else { return } withAnimation(animation) { drag = nil } if let at = arrangement.people.firstIndex(of: peer) { AudioAnnouncer.say(StageWords.moved(title, to: at, of: arrangement.people.count)) } } } /// Move earlier, Move later and Move to first, as accessibility actions, /// on a person's tile (a screen has none). private struct MoveActions: ViewModifier { let peer: String? @ObservedObject var arrangement: CallArrangement func body(content: Content) -> some View { if let peer { content .accessibilityAction(named: StageWords.moveEarlier) { arrangement.moveEarlier(peer) } .accessibilityAction(named: StageWords.moveLater) { arrangement.moveLater(peer) } .accessibilityAction(named: StageWords.moveToFirst) { arrangement.moveToFirst(peer) } } else { content } } } /// The pin on a pinned stage: says it is pinned, and unpins when pressed. public struct PinBadge: View { let title: String let unpin: () -> Void public init(title: String, unpin: @escaping () -> Void) { self.title = title self.unpin = unpin } public var body: some View { Button(action: unpin) { Label("Pinned", systemImage: "pin.fill") .font(.callout.weight(.semibold)).foregroundStyle(.white) .padding(.horizontal, 10).padding(.vertical, 6) .callOverlaySurface(Capsule()) } .buttonStyle(.plain) .help("Unpin \(title): the stage follows whoever is speaking") .accessibilityLabel(StageWords.unpin(title)) .accessibilityHint("The stage follows whoever is speaking") .accessibilityIdentifier("unpin") } }