117 lines
3.7 KiB
Swift
117 lines
3.7 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import SwiftUI
|
|
|
|
public nonisolated struct DropTargetPresentation: Equatable, Sendable {
|
|
public nonisolated enum Tone: Equatable, Sendable {
|
|
case accent
|
|
case unavailable
|
|
}
|
|
|
|
public let title: String
|
|
public let detail: String
|
|
public let systemImage: String
|
|
public var tone: Tone
|
|
|
|
public init(
|
|
title: String,
|
|
detail: String,
|
|
systemImage: String,
|
|
tone: Tone = .accent
|
|
) {
|
|
self.title = title
|
|
self.detail = detail
|
|
self.systemImage = systemImage
|
|
self.tone = tone
|
|
}
|
|
}
|
|
|
|
/// One unmistakable drop affordance for every full-surface destination.
|
|
///
|
|
/// The content under it remains visible enough to retain place, while the
|
|
/// moving dashed border and the compact glass card make the pending action
|
|
/// legible over a terminal, a file list, or a transcript.
|
|
public struct DropTargetOverlay: View {
|
|
public let presentation: DropTargetPresentation
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var animatesSymbol = false
|
|
|
|
private let cornerRadius: CGFloat = 10
|
|
private var tint: Color {
|
|
presentation.tone == .unavailable ? .orange : .accentColor
|
|
}
|
|
|
|
public init(presentation: DropTargetPresentation) {
|
|
self.presentation = presentation
|
|
}
|
|
|
|
public var body: some View {
|
|
ZStack {
|
|
tint.opacity(0.09)
|
|
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.stroke(
|
|
tint,
|
|
style: StrokeStyle(lineWidth: 2, dash: [7, 5])
|
|
)
|
|
.padding(8)
|
|
|
|
VStack(spacing: 6) {
|
|
Image(systemName: presentation.systemImage)
|
|
.font(.system(size: 24, weight: .semibold))
|
|
.foregroundStyle(tint)
|
|
.symbolEffect(
|
|
.bounce.up,
|
|
options: reduceMotion ? .nonRepeating : .repeating.speed(0.55),
|
|
value: animatesSymbol
|
|
)
|
|
Text(presentation.title)
|
|
.font(.headline)
|
|
Text(presentation.detail)
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.truncationMode(.middle)
|
|
}
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.modifier(DropTargetCardSurface(cornerRadius: cornerRadius))
|
|
}
|
|
.allowsHitTesting(false)
|
|
.accessibilityElement(children: .combine)
|
|
.transition(
|
|
reduceMotion
|
|
? .opacity
|
|
: .opacity.combined(with: .scale(scale: 0.96))
|
|
)
|
|
.onAppear { animatesSymbol = true }
|
|
}
|
|
}
|
|
|
|
private struct DropTargetCardSurface: ViewModifier {
|
|
let cornerRadius: CGFloat
|
|
|
|
@ViewBuilder
|
|
func body(content: Content) -> some View {
|
|
let shape = RoundedRectangle(cornerRadius: cornerRadius)
|
|
if #available(iOS 26.0, macOS 26.0, *) {
|
|
content
|
|
.glassEffect(.regular, in: shape)
|
|
.overlay {
|
|
shape.strokeBorder(Color.primary.opacity(0.10), lineWidth: 1)
|
|
}
|
|
.shadow(color: .black.opacity(0.16), radius: 12, y: 5)
|
|
} else {
|
|
content
|
|
.background(.regularMaterial, in: shape)
|
|
.overlay {
|
|
shape.strokeBorder(Color.primary.opacity(0.10), lineWidth: 1)
|
|
}
|
|
.shadow(color: .black.opacity(0.16), radius: 12, y: 5)
|
|
}
|
|
}
|
|
}
|