Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Flipcash/Core/Screens/Conversation/AttachCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ final class AttachCard {

/// That chip's frame in window coordinates, once the bar has laid it out.
private(set) var landingChipFrame: CGRect?
/// The photo the card is shrinking into the chip, drawn by the surface for the whole landing.
private(set) var landingImage: UIImage?

/// Whether the keyboard coming up takes the card down. Off while the card is drawn over the
/// keyboard, where the keyboard never left, and an input view swap announces it again.
Expand Down Expand Up @@ -105,9 +107,10 @@ final class AttachCard {

/// Marks `chipID` as the chip the card will shrink onto, keeping the card up until the bar has
/// laid that chip out.
func beginLanding(on chipID: ComposerChip.ID) {
func beginLanding(on chipID: ComposerChip.ID, image: UIImage? = nil) {
landingChipID = chipID
landingChipFrame = nil
landingImage = image
}

/// Records where the landing chip was laid out. Returns whether the card is still up and can now
Expand All @@ -127,6 +130,7 @@ final class AttachCard {
withTransaction(transaction) {
landingChipID = nil
landingChipFrame = nil
landingImage = nil
}
}

Expand Down
12 changes: 5 additions & 7 deletions Flipcash/Core/Screens/Conversation/AttachMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ enum AttachMenuItem: Equatable {
}
}

/// The composer's leading `+` control, in the slot Send Cash takes before the chat exists. Opens the
/// attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over
/// The `+` circle in the composer field's bottom row. Opens the attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over
/// the field and collapses back into it — and reports where it is laid out, for the surface to grow
/// from. The surface itself is ``AttachSurface``, drawn by the bar or over the keyboard.
struct AttachMenu: View {
Expand All @@ -61,14 +60,13 @@ struct AttachMenu: View {
panel.animate { $0.toggle() }
} label: {
Image(systemName: SystemSymbol.plus.rawValue)
.font(.default(size: 20, weight: .semibold))
.font(.default(size: 17, weight: .semibold))
.foregroundStyle(Color.textMain)
.frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight)
.contentShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius))
.frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize)
.background(AttachSurfaceLayout.plusFill, in: Circle())
.contentShape(Circle())
}
.buttonStyle(.plain)
.glassBackground(cornerRadius: BarMetrics.cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius))
.accessibilityLabel("Attach")
.accessibilityValue(panel.isOpen ? "Expanded" : "Collapsed")
.accessibilityIdentifier("attach-menu-button")
Expand Down
16 changes: 14 additions & 2 deletions Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ final class AttachOverKeyboard {
/// `+`'s frame in window coordinates, as last laid out.
var plusFrame: CGRect = .zero

/// How far left of the composer field the bar's row starts: `$`'s width and gap while it shows,
/// else zero. The menu opens out to there, so it covers the row it opens from.
var menuLeadingReach: CGFloat = 0

/// Whether the photo card opens the full library as it next mounts in the bar. Set when All
/// Photos hands the card over to the keyboard-down flow, whose sheet the keyboard would cover.
var opensLibraryInBar = false
Expand All @@ -101,13 +105,21 @@ nonisolated enum AttachOverlayLayout {
/// The margin between a card and the screen's sides and bottom: the bar's keyboard-up edge inset.
static let cardInset: CGFloat = 12

/// The panel's frame for a panel `size` big: its leading edge on `+`'s, and centred on `+`'s
/// The panel's frame for a panel `size` big: its leading edge on the composer field's, which is
/// the field's padding out from `+`'s, and centred on `+`'s
/// bottom, the composer's bottom edge. It straddles that edge, as ChatGPT's does, so its lower
/// half lies over the keys that blur through it and its upper half over the field.
static func panelFrame(plusFrame: CGRect, size: CGSize) -> CGRect {
CGRect(x: plusFrame.minX, y: plusFrame.maxY - size.height / 2, width: size.width, height: size.height)
// Centred on the field's bottom edge, a short menu tops out just under the field's top and
// leaves its corner showing; lift it to clear the top by `fieldTopOverhang`.
let fieldTop = plusFrame.maxY + BarMetrics.fieldPadding - BarMetrics.contentHeight
let y = min(plusFrame.maxY - size.height / 2, fieldTop - fieldTopOverhang)
return CGRect(x: plusFrame.minX - BarMetrics.fieldPadding, y: y, width: size.width, height: size.height)
}

/// How far the open menu reaches above the composer field's top edge.
static let fieldTopOverhang: CGFloat = 8

/// The point in a panel `size` big that `+`'s centre sits on, for the panel to grow out of and
/// collapse back into.
static func panelAnchor(plusFrame: CGRect, size: CGSize) -> UnitPoint {
Expand Down
6 changes: 4 additions & 2 deletions Flipcash/Core/Screens/Conversation/AttachPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ final class AttachPanel {
}

/// Runs `change` on the panel's spring, and releases the room above the bar once the motion it
/// started has logically finished.
/// started has finished: logically for an open, fully settled for a close, so the surface is not
/// taken down before it has reached `+`.
func animate(_ change: (AttachPanel) -> Void) {
withAnimation(ChatMotion.attachPanel.animation, completionCriteria: .logicallyComplete) {
let closes = isOpen
withAnimation(ChatMotion.attachPanel.animation, completionCriteria: closes ? .removed : .logicallyComplete) {
change(self)
} completion: { [weak self] in
self?.exitDidFinish()
Expand Down
91 changes: 76 additions & 15 deletions Flipcash/Core/Screens/Conversation/AttachSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,28 +67,41 @@ nonisolated struct AttachSurfaceShape: Equatable {
/// Where the attach surface stands in each phase, in its host's coordinates.
enum AttachSurfaceLayout {

/// `+`'s radius, which the surface grows out of and collapses back into.
static let plusCornerRadius: CGFloat = BarMetrics.cornerRadius
static let menuCornerRadius: CGFloat = 40
/// Matches the composer field's corner, so the menu reads as part of it.
static let menuCornerRadius: CGFloat = BarMetrics.fieldCornerRadius
/// `+`'s flat fill on the composer field, which the surface turns into as it reaches `+`.
static let plusFill = Color.white.opacity(0.10)
/// How far past `+`'s size the surface has fully turned from `+`'s fill into the panel's glass.
static let plusBlendDistance: CGFloat = 40
static let cardCornerRadius: CGFloat = 24
/// A staged chip's radius, which a landing ends on.
static let chipCornerRadius: CGFloat = 10
/// The menu's width: enough to cover the field from `+`, short of the send button on a 375pt screen.
static let menuWidth: CGFloat = 280
/// The menu's width: room for an icon and a short label, well short of the send button.
static let menuWidth: CGFloat = 220
/// A menu row's height, sized to the system's own attachment menus.
static let menuRowHeight: CGFloat = 62
static let menuVerticalPadding: CGFloat = 10
/// The rows' blur and scale while hidden, which they sharpen and grow out of as the menu opens.
static let rowsBloomBlur: CGFloat = 8
static let rowsBloomScale: CGFloat = 0.85

/// The menu's size before its rows are measured.
static func estimatedMenuSize(rowCount: Int) -> CGSize {
CGSize(width: menuWidth, height: CGFloat(rowCount) * menuRowHeight + menuVerticalPadding * 2)
}

/// The menu's frame for a menu `size` big, placed against `plus` by `placement`.
static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect {
/// The menu's frame for a menu `size` big, placed against `plus` by `placement`. Either way its
/// leading edge is the composer field's, ``BarMetrics/fieldPadding`` out from `+`'s, moved out a
/// further `leadingReach` at the same width; standing on `+`, its bottom edge is the field's too.
static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement, leadingReach: CGFloat = 0) -> CGRect {
let rect = fieldAlignedMenuRect(plus: plus, size: size, placement: placement)
return CGRect(x: rect.minX - leadingReach, y: rect.minY, width: rect.width, height: rect.height)
}

private static func fieldAlignedMenuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect {
switch placement {
case .standsOnPlus:
CGRect(x: plus.minX, y: plus.maxY - size.height, width: size.width, height: size.height)
CGRect(x: plus.minX - BarMetrics.fieldPadding, y: plus.maxY + BarMetrics.fieldPadding - size.height, width: size.width, height: size.height)
case .straddlesPlus:
AttachOverlayLayout.panelFrame(plusFrame: plus, size: size)
}
Expand All @@ -100,11 +113,16 @@ enum AttachSurfaceLayout {
CGRect(x: -outset, y: row.height - height, width: row.width + outset * 2, height: height)
}

/// Returns the radius of the surface collapsed onto `plus`, which is a circle: half its height.
static func collapsedCornerRadius(plus: CGRect) -> CGFloat {
plus.height / 2
}

/// The surface's shape in `phase`. A landing whose chip hasn't been laid out yet holds the card's.
static func shape(for phase: AttachSurfacePhase, plus: CGRect, menu: CGRect, card: CGRect, landing: CGRect?) -> AttachSurfaceShape {
switch phase {
case .collapsed:
AttachSurfaceShape(rect: plus, cornerRadius: plusCornerRadius)
AttachSurfaceShape(rect: plus, cornerRadius: collapsedCornerRadius(plus: plus))
case .menu:
AttachSurfaceShape(rect: menu, cornerRadius: menuCornerRadius)
case .card:
Expand Down Expand Up @@ -162,21 +180,34 @@ struct AttachSurface: View {
let menu = AttachSurfaceLayout.menuRect(
plus: plus,
size: menuSize ?? AttachSurfaceLayout.estimatedMenuSize(rowCount: items.count),
placement: menuPlacement
placement: menuPlacement,
leadingReach: model.overKeyboard.menuLeadingReach
)
let shape = AttachSurfaceLayout.shape(for: appeared ? phase : .collapsed, plus: plus, menu: menu, card: card, landing: landing)
// Morphing, the surface is `+` at either end and stays opaque; a cross-fade fades it instead.
let isVisible = motion.animatesGeometry || (appeared && phase != .collapsed)
let showsPlusGlyph = !appeared || phase == .collapsed
AttachSurfaceFrame(shape: shape) { rect in
AttachSurfaceFrame(shape: shape, plus: plus, blendsIntoPlus: phase != .landing) { rect in
ZStack(alignment: .topLeading) {
plusGlyph
.offset(x: plus.minX - rect.minX, y: plus.minY - rect.minY)
.attachLayer(isShown: showsPlusGlyph)
cardLayer(phase: phase)
.frame(width: card.width, height: card.height)
.offset(x: card.minX - rect.minX, y: card.minY - rect.minY)
rows(phase: phase)
// From the hand-off on, filling the surface at every step: the viewfinder holds the
// capture, and it collapses into the chip rather than an empty surface flying there.
if let image = model.attachCard.landingImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: rect.width, height: rect.height)
.clipped()
.allowsHitTesting(false)
.accessibilityHidden(true)
.transition(.identity)
}
rows(phase: phase, menu: menu, motion: motion)
.frame(width: menu.width, height: menu.height, alignment: .topLeading)
.offset(x: menu.minX - rect.minX, y: menu.minY - rect.minY)
}
Expand Down Expand Up @@ -207,21 +238,33 @@ struct AttachSurface: View {
/// `+`'s glyph where `+` stands, so the surface reads as `+` itself as it grows and collapses.
private var plusGlyph: some View {
Image(systemName: SystemSymbol.plus.rawValue)
.font(.default(size: 20, weight: .semibold))
.font(.default(size: 17, weight: .semibold))
.foregroundStyle(Color.textMain)
.frame(width: plus.width, height: plus.height)
.accessibilityHidden(true)
}

private func rows(phase: AttachSurfacePhase) -> some View {
/// The rows sharpen out of a blur and grow from `+` as the surface opens, and go back into it as
/// it closes, so they read as poured out of `+` rather than faded over it.
private func rows(phase: AttachSurfacePhase, menu: CGRect, motion: AttachMotion) -> some View {
let panel = model.attachPanel
let shown = phase.showsRows
let blooms = motion.animatesGeometry
let anchor = UnitPoint(
x: menu.width > 0 ? (plus.midX - menu.minX) / menu.width : 0,
y: menu.height > 0 ? (plus.midY - menu.minY) / menu.height : 1
)
return AttachMenuRows(items: items, onSelect: { item in
panel.select(item, warmUp: model.attachWarmUp, onCash: actions.onCash, onCamera: actions.onCamera, onPhotos: actions.onPhotos)
}) {
panel.animate { $0.dismiss() }
}
.onGeometryChange(for: CGSize.self, of: { $0.size }) { menuSize = $0 }
.attachLayer(isShown: phase.showsRows)
.animation(ChatMotion.attachPanel.animation) {
$0.blur(radius: shown || !blooms ? 0 : AttachSurfaceLayout.rowsBloomBlur)
.scaleEffect(shown || !blooms ? 1 : AttachSurfaceLayout.rowsBloomScale, anchor: anchor)
}
.attachLayer(isShown: shown)
}

/// The camera and the photo picker, each mounted from the surface's first frame at the card's
Expand Down Expand Up @@ -271,6 +314,10 @@ private extension View {
private struct AttachSurfaceFrame<Content: View>: View, Animatable {

var shape: AttachSurfaceShape
/// `+`'s frame, which the surface takes on the look of as it shrinks to `+`'s size.
let plus: CGRect
/// Whether the surface turns into `+`'s fill near `+`'s size; not while it lands on a chip.
let blendsIntoPlus: Bool
let content: (CGRect) -> Content

var animatableData: AnimatablePair<CGRect.AnimatableData, CGFloat> {
Expand All @@ -284,15 +331,29 @@ private struct AttachSurfaceFrame<Content: View>: View, Animatable {
var body: some View {
let rect = shape.rect
let clip = RoundedRectangle(cornerRadius: shape.cornerRadius, style: .continuous)
let glass = glassAmount(at: rect)
ZStack(alignment: .topLeading) {
clip
.fill(AttachSurfaceLayout.plusFill)
.frame(width: rect.width, height: rect.height)
.opacity(1 - glass)
Color.clear
.frame(width: rect.width, height: rect.height)
.panelGlassBackground(cornerRadius: shape.cornerRadius)
.opacity(glass)
content(rect)
}
.frame(width: rect.width, height: rect.height, alignment: .topLeading)
.clipShape(clip)
.contentShape(clip)
.offset(x: rect.minX, y: rect.minY)
}

/// How much of the panel's glass shows at `rect`, against `+`'s flat fill: none at `+`'s size,
/// all of it once the surface is a few dozen points bigger, so the two meet without a colour jump.
private func glassAmount(at rect: CGRect) -> CGFloat {
guard blendsIntoPlus else { return 1 }
let growth = max(rect.width - plus.width, rect.height - plus.height)
return min(max(growth / AttachSurfaceLayout.plusBlendDistance, 0), 1)
}
}
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ struct ChatCameraSheet: View {
}
}

/// A round control over the viewfinder: a white glyph on a dimmed disc.
/// A round control over the viewfinder: a white glyph on dark-tinted glass.
private struct ChatCameraControlLabel: View {

let systemImage: String
Expand All @@ -251,7 +251,7 @@ private struct ChatCameraControlLabel: View {
.font(.default(size: 17, weight: .semibold))
.foregroundStyle(Color.white)
.frame(width: 44, height: 44)
.background(Circle().fill(Color.black.opacity(0.35)))
.overlayGlassBackground(in: Circle())
.contentShape(Circle())
}
}
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ struct ChatPhotosCard: View {
.contentShape(Circle())
}
.buttonStyle(.plain)
.glassBackground(cornerRadius: 22)
.overlayGlassBackground(in: Circle())
.clipShape(Circle())
.accessibilityLabel("Back to attach menu")
.accessibilityIdentifier("attach-photos-back")
Expand All @@ -155,7 +155,7 @@ struct ChatPhotosCard: View {
.contentShape(Capsule())
}
.buttonStyle(.plain)
.glassBackground(cornerRadius: 22)
.overlayGlassBackground(in: Capsule())
.clipShape(Capsule())
.accessibilityIdentifier("attach-photos-all")
.transition(.opacity)
Expand Down
20 changes: 11 additions & 9 deletions Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
screen?.focusComposer()
return
}
card.beginLanding(on: chipID)
let chip = composer.chips.first { $0.id == chipID }
card.beginLanding(on: chipID, image: chip.map { $0.preview ?? $0.image })
screen?.focusComposer()
// A chip the bar never lays out leaves nothing to land on, so the card goes anyway.
DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.attachCard.duration) {
Expand Down Expand Up @@ -473,13 +474,14 @@ private struct MeasuredBarHeight: ViewModifier {
content
.fixedSize(horizontal: false, vertical: true)
.overlayPreferenceValue(BarAccessoriesKey.self) { accessories in
GeometryReader { proxy in
Color.clear.onChange(
of: BarReport(height: proxy.size.height, accessories: accessories),
initial: true
) { _, new in
report(new.height, new.accessories)
}
// `onGeometryChange`, not a `GeometryReader`: under an animated layout change the
// reader reports the height the animation starts from and then goes quiet, which
// leaves the clip short of the content it settles at.
Color.clear.onGeometryChange(
for: BarReport.self,
of: { BarReport(height: $0.size.height, accessories: accessories) }
) { new in
report(new.height, new.accessories)
}
}
// Sit on the host's bottom edge rather than in the middle of it. The two heights are
Expand All @@ -493,7 +495,7 @@ private struct MeasuredBarHeight: ViewModifier {
}

/// One measurement of the bar: its height and the cards that make it up.
private struct BarReport: Equatable {
private nonisolated struct BarReport: Equatable, Sendable {
let height: CGFloat
let accessories: BarAccessories
}
Loading
Loading