Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
641 lines
26 KiB
Swift
641 lines
26 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
import Combine
|
|
|
|
struct MainTabView: View {
|
|
@EnvironmentObject var audioPlayer: AudioPlayer
|
|
@ObservedObject private var debugLogger = DebugLogger.shared
|
|
@State private var selectedTab = 0
|
|
@State private var navigateToPlaylistId: String?
|
|
@State private var navigateToAlbumId: String?
|
|
@State private var navigateToArtistId: String?
|
|
@State var showNowPlaying = false
|
|
|
|
// Debug panel (docked)
|
|
@State private var debugPanelHeight: CGFloat = 250
|
|
@State private var debugDragOffset: CGFloat = 0
|
|
|
|
// Debug PiP (floating)
|
|
@State private var debugPipMode = false
|
|
@State private var pipPosition: CGPoint = CGPoint(x: 16, y: 100)
|
|
@State private var pipDragOffset: CGSize = .zero
|
|
@State private var pipSize: CGSize = CGSize(width: 320, height: 220)
|
|
@State private var pipCollapsed = false
|
|
@State private var pipResizeStart: CGSize = .zero
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Main tab content
|
|
ZStack(alignment: .bottom) {
|
|
TabView(selection: $selectedTab) {
|
|
MyMusicView(
|
|
navigateToPlaylistId: $navigateToPlaylistId,
|
|
navigateToAlbumId: $navigateToAlbumId,
|
|
navigateToArtistId: $navigateToArtistId
|
|
)
|
|
.tabItem {
|
|
Image(systemName: "music.note")
|
|
Text("My Music")
|
|
}
|
|
.tag(0)
|
|
|
|
SearchView()
|
|
.tabItem {
|
|
Image(systemName: "magnifyingglass")
|
|
Text("Search")
|
|
}
|
|
.tag(1)
|
|
|
|
RadioView()
|
|
.tabItem {
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
Text("Radio")
|
|
}
|
|
.tag(2)
|
|
|
|
DownloadsView()
|
|
.tabItem {
|
|
Image(systemName: "arrow.down.circle")
|
|
Text("Downloads")
|
|
}
|
|
.tag(3)
|
|
|
|
SettingsView()
|
|
.tabItem {
|
|
Image(systemName: "gear")
|
|
Text("Settings")
|
|
}
|
|
.tag(4)
|
|
}
|
|
.tint(accentPink)
|
|
|
|
// Debug panel (docked) — only when enabled and NOT in PiP mode
|
|
if debugLogger.isEnabled && !debugPipMode {
|
|
debugPanel
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.zIndex(2)
|
|
}
|
|
|
|
if audioPlayer.currentSong != nil {
|
|
let effectiveDebugHeight = max(debugPanelHeight - debugDragOffset, 120)
|
|
let showDocked = debugLogger.isEnabled && !debugPipMode
|
|
MiniPlayerBar(showNowPlaying: $showNowPlaying)
|
|
.padding(.bottom, showDocked ? 49 + effectiveDebugHeight : 49)
|
|
.animation(.easeInOut(duration: 0.25), value: showDocked)
|
|
.zIndex(3)
|
|
}
|
|
}
|
|
|
|
// Now Playing overlay — always rendered, positioned via offset
|
|
// This avoids re-layout animation when appearing
|
|
if audioPlayer.currentSong != nil {
|
|
NowPlayingView(isPresented: $showNowPlaying)
|
|
.zIndex(1)
|
|
.offset(y: showNowPlaying ? 0 : 1500)
|
|
.allowsHitTesting(showNowPlaying)
|
|
}
|
|
|
|
// Debug PiP (floating) — above everything except Now Playing
|
|
if debugLogger.isEnabled && debugPipMode {
|
|
debugPipView
|
|
.zIndex(showNowPlaying ? 0 : 5)
|
|
.opacity(showNowPlaying ? 0 : 1)
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToPlaylist)) { notif in
|
|
if let playlistId = notif.userInfo?["playlistId"] as? String {
|
|
selectedTab = 0
|
|
withAnimation(.easeInOut(duration: 0.35)) {
|
|
showNowPlaying = false
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
|
navigateToPlaylistId = playlistId
|
|
}
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToAlbum)) { notif in
|
|
if let albumId = notif.userInfo?["albumId"] as? String {
|
|
selectedTab = 0
|
|
withAnimation(.easeInOut(duration: 0.35)) {
|
|
showNowPlaying = false
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
|
navigateToAlbumId = albumId
|
|
}
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToArtist)) { notif in
|
|
if let artistId = notif.userInfo?["artistId"] as? String {
|
|
selectedTab = 0
|
|
withAnimation(.easeInOut(duration: 0.35)) {
|
|
showNowPlaying = false
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
|
navigateToArtistId = artistId
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Debug Panel
|
|
|
|
private var debugPanel: some View {
|
|
VStack(spacing: 0) {
|
|
// Drag handle + header
|
|
VStack(spacing: 4) {
|
|
Capsule()
|
|
.fill(Color.white.opacity(0.4))
|
|
.frame(width: 36, height: 4)
|
|
.padding(.top, 8)
|
|
|
|
HStack {
|
|
Image(systemName: "ladybug.fill")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.red)
|
|
Text("Debug Console")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
|
|
Text("\(DebugLogger.shared.entries.count)")
|
|
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
|
.foregroundColor(.gray)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.white.opacity(0.1))
|
|
.cornerRadius(8)
|
|
|
|
Spacer()
|
|
|
|
Button(action: { DebugLogger.shared.clear() }) {
|
|
Text("Clear")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(.red.opacity(0.8))
|
|
}
|
|
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
debugPipMode = true
|
|
}
|
|
}) {
|
|
Image(systemName: "pip")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
}
|
|
|
|
Button(action: {
|
|
debugLogger.isEnabled = false
|
|
}) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.bottom, 6)
|
|
}
|
|
.background(Color(white: 0.1))
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { value in
|
|
debugDragOffset = value.translation.height
|
|
}
|
|
.onEnded { value in
|
|
let newHeight = debugPanelHeight - value.translation.height
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
|
debugDragOffset = 0
|
|
debugPanelHeight = min(max(newHeight, 120), 500)
|
|
}
|
|
}
|
|
)
|
|
|
|
Divider().background(Color.white.opacity(0.15))
|
|
|
|
// Log entries
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 1) {
|
|
ForEach(DebugLogger.shared.entries) { entry in
|
|
debugLogRow(entry)
|
|
.id(entry.id)
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
}
|
|
.onChange(of: DebugLogger.shared.entries.count) { _, _ in
|
|
if let last = DebugLogger.shared.entries.last {
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
proxy.scrollTo(last.id, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: max(debugPanelHeight - debugDragOffset, 120))
|
|
.background(Color(white: 0.06))
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.shadow(color: .black.opacity(0.4), radius: 8, y: -2)
|
|
.padding(.horizontal, 4)
|
|
.padding(.bottom, 49) // above tab bar
|
|
}
|
|
|
|
private func debugLogRow(_ entry: DebugLogger.LogEntry) -> some View {
|
|
let color: Color = {
|
|
switch entry.category {
|
|
case "Watch": return .cyan
|
|
case "Audio": return .green
|
|
case "FFT": return .orange
|
|
case "Network": return .yellow
|
|
case "Error": return .red
|
|
case "Radio": return .purple
|
|
default: return .gray
|
|
}
|
|
}()
|
|
|
|
return HStack(alignment: .top, spacing: 6) {
|
|
Text(entry.category)
|
|
.font(.system(size: 9, weight: .bold, design: .monospaced))
|
|
.foregroundColor(color)
|
|
.padding(.horizontal, 3)
|
|
.padding(.vertical, 1)
|
|
.background(color.opacity(0.15))
|
|
.cornerRadius(3)
|
|
|
|
Text(entry.message)
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundColor(.white.opacity(0.85))
|
|
.lineLimit(3)
|
|
}
|
|
.padding(.vertical, 3)
|
|
}
|
|
|
|
// MARK: - Debug PiP (Floating)
|
|
|
|
private var debugPipView: some View {
|
|
let currentPos = CGPoint(
|
|
x: pipPosition.x + pipDragOffset.width,
|
|
y: pipPosition.y + pipDragOffset.height
|
|
)
|
|
|
|
return VStack(spacing: 0) {
|
|
// PiP header — always visible
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "ladybug.fill")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.red)
|
|
|
|
Text("Debug")
|
|
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
|
.foregroundColor(.white)
|
|
|
|
Text("\(DebugLogger.shared.entries.count)")
|
|
.font(.system(size: 9, weight: .medium, design: .monospaced))
|
|
.foregroundColor(.gray)
|
|
|
|
Spacer()
|
|
|
|
Button(action: { DebugLogger.shared.clear() }) {
|
|
Image(systemName: "trash")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.red.opacity(0.7))
|
|
}
|
|
|
|
// Collapse/expand
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
pipCollapsed.toggle()
|
|
}
|
|
}) {
|
|
Image(systemName: pipCollapsed ? "chevron.down" : "chevron.up")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
}
|
|
|
|
// Dock back
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
debugPipMode = false
|
|
}
|
|
}) {
|
|
Image(systemName: "dock.rectangle")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
}
|
|
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
debugLogger.isEnabled = false
|
|
debugPipMode = false
|
|
}
|
|
}) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 7)
|
|
.background(Color(white: 0.12))
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { value in
|
|
pipDragOffset = value.translation
|
|
}
|
|
.onEnded { value in
|
|
pipPosition = CGPoint(
|
|
x: pipPosition.x + value.translation.width,
|
|
y: pipPosition.y + value.translation.height
|
|
)
|
|
pipDragOffset = .zero
|
|
}
|
|
)
|
|
|
|
// Log content — hidden when collapsed
|
|
if !pipCollapsed {
|
|
Divider().background(Color.white.opacity(0.1))
|
|
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 1) {
|
|
ForEach(DebugLogger.shared.entries) { entry in
|
|
debugLogRow(entry)
|
|
.id(entry.id)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
}
|
|
.onChange(of: DebugLogger.shared.entries.count) { _, _ in
|
|
if let last = DebugLogger.shared.entries.last {
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
proxy.scrollTo(last.id, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: pipCollapsed ? 0 : pipSize.height - 32)
|
|
|
|
// Resize handle
|
|
HStack {
|
|
Spacer()
|
|
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(.white.opacity(0.3))
|
|
.padding(6)
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { value in
|
|
if pipResizeStart == .zero {
|
|
pipResizeStart = pipSize
|
|
}
|
|
let newW = max(200, pipResizeStart.width + value.translation.width)
|
|
let newH = max(100, pipResizeStart.height + value.translation.height)
|
|
pipSize = CGSize(width: min(newW, 500), height: min(newH, 500))
|
|
}
|
|
.onEnded { _ in
|
|
pipResizeStart = .zero
|
|
}
|
|
)
|
|
}
|
|
.frame(height: 18)
|
|
.background(Color(white: 0.08))
|
|
}
|
|
}
|
|
.frame(width: pipSize.width)
|
|
.background(Color(white: 0.06).opacity(0.95))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.stroke(Color.white.opacity(0.08), lineWidth: 0.5)
|
|
)
|
|
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
|
|
.position(x: currentPos.x + pipSize.width / 2, y: currentPos.y + (pipCollapsed ? 16 : pipSize.height / 2))
|
|
}
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let navigateToPlaylist = Notification.Name("navigateToPlaylist")
|
|
static let navigateToAlbum = Notification.Name("navigateToAlbum")
|
|
static let navigateToArtist = Notification.Name("navigateToArtist")
|
|
}
|
|
|
|
// MARK: - Mini Player Bar with scrubbable progress
|
|
struct MiniPlayerBar: View {
|
|
@EnvironmentObject var audioPlayer: AudioPlayer
|
|
@StateObject private var colorExtractor = AlbumColorExtractor.shared
|
|
@Binding var showNowPlaying: Bool
|
|
@State private var isScrubbing = false
|
|
@State private var scrubPosition: Double = 0
|
|
@State private var playbackTime: TimeInterval = 0
|
|
@State private var playbackDuration: TimeInterval = 0
|
|
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
// Poll currentTime at our own pace — doesn't trigger parent view redraws
|
|
private let timePoller = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
|
|
|
|
private var displayProgress: Double {
|
|
if isScrubbing { return scrubPosition }
|
|
guard playbackDuration > 0 else { return 0 }
|
|
return min(playbackTime / playbackDuration, 1.0)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Scrubbable progress bar at top — uses album color, generous touch target
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Rectangle()
|
|
.fill(Color.white.opacity(0.1))
|
|
.frame(height: isScrubbing ? 8 : 3)
|
|
|
|
Rectangle()
|
|
.fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink)
|
|
.frame(
|
|
width: geo.size.width * displayProgress,
|
|
height: isScrubbing ? 8 : 3
|
|
)
|
|
|
|
if isScrubbing {
|
|
Circle()
|
|
.fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink)
|
|
.frame(width: 16, height: 16)
|
|
.offset(x: geo.size.width * displayProgress - 8, y: -1)
|
|
}
|
|
}
|
|
.frame(maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { value in
|
|
isScrubbing = true
|
|
scrubPosition = min(max(value.location.x / geo.size.width, 0), 1)
|
|
}
|
|
.onEnded { value in
|
|
let pct = min(max(value.location.x / geo.size.width, 0), 1)
|
|
audioPlayer.seekToPercent(pct)
|
|
isScrubbing = false
|
|
}
|
|
)
|
|
}
|
|
.frame(height: isScrubbing ? 20 : 14) // Generous touch target even when not scrubbing
|
|
.animation(.easeInOut(duration: 0.15), value: isScrubbing)
|
|
|
|
// Player content
|
|
ZStack(alignment: .center) {
|
|
// Visualizer behind controls — paused when full NowPlaying is open
|
|
if VisualizerSettings.shared.enabled && VisualizerSettings.shared.miniPlayerEnabled && !showNowPlaying {
|
|
CompactVisualizerView(
|
|
isPlaying: audioPlayer.isPlaying,
|
|
accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink,
|
|
height: VisualizerSettings.shared.miniPlayerHeight
|
|
)
|
|
.offset(y: 10)
|
|
.opacity(VisualizerSettings.shared.miniOpacity)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
// Cover art
|
|
Group {
|
|
if let song = audioPlayer.currentSong,
|
|
(song.album == "Radio" || audioPlayer.isRadioStream),
|
|
let img = RadioCoverStore.shared.loadCover(for: song.id) {
|
|
Image(uiImage: img)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else {
|
|
AsyncCoverArt(
|
|
coverArtId: audioPlayer.currentSong?.coverArt,
|
|
size: 48
|
|
)
|
|
}
|
|
}
|
|
.frame(width: 44, height: 44)
|
|
.cornerRadius(6)
|
|
.shadow(radius: 3)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(audioPlayer.currentSong?.title ?? "")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Text(audioPlayer.currentSong?.artist ?? "")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(action: { audioPlayer.previous() }) {
|
|
Image(systemName: "backward.fill")
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.white)
|
|
}
|
|
.frame(width: 36, height: 44)
|
|
|
|
Button(action: { audioPlayer.togglePlayPause() }) {
|
|
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
|
|
.font(.system(size: 22))
|
|
.foregroundColor(.white)
|
|
}
|
|
.frame(width: 44, height: 44)
|
|
|
|
Button(action: { audioPlayer.next() }) {
|
|
Image(systemName: "forward.fill")
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.white)
|
|
}
|
|
.frame(width: 36, height: 44)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
.frame(height: 60)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
if !isScrubbing {
|
|
dismissKeyboard()
|
|
withAnimation(.easeInOut(duration: 0.35)) {
|
|
showNowPlaying = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.background(.ultraThinMaterial)
|
|
.background(
|
|
colorExtractor.isLoaded ? colorExtractor.primaryColor.opacity(0.15) : Color.clear
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
.shadow(color: .black.opacity(0.3), radius: 8, y: 4)
|
|
.padding(.horizontal, 8)
|
|
.onReceive(timePoller) { _ in
|
|
playbackTime = audioPlayer.currentTime
|
|
playbackDuration = audioPlayer.duration
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Keyboard Dismiss
|
|
|
|
/// Dismiss keyboard from anywhere
|
|
func dismissKeyboard() {
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
}
|
|
|
|
/// UIKit tap recognizer that dismisses keyboard without blocking other touches.
|
|
/// Added once to the key window — fires alongside all gestures.
|
|
private class KeyboardDismissTapRecognizer: UITapGestureRecognizer, UIGestureRecognizerDelegate {
|
|
override init(target: Any?, action: Selector?) {
|
|
super.init(target: target, action: action)
|
|
cancelsTouchesInView = false
|
|
delegate = self
|
|
}
|
|
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
true // Never block other gestures
|
|
}
|
|
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
// Don't fire when tapping on text inputs themselves
|
|
!(touch.view is UITextField || touch.view is UITextView)
|
|
}
|
|
}
|
|
|
|
/// Helper target for the tap gesture — calls endEditing on the window
|
|
private class KeyboardDismissTarget: NSObject {
|
|
@objc func dismiss() {
|
|
dismissKeyboard()
|
|
}
|
|
}
|
|
|
|
/// View modifier that installs a one-time keyboard dismiss gesture on the window.
|
|
struct KeyboardDismissible: ViewModifier {
|
|
// Held as static so the target isn't deallocated
|
|
private static let target = KeyboardDismissTarget()
|
|
|
|
func body(content: Content) -> some View {
|
|
content.onAppear {
|
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
|
let window = windowScene.windows.first else { return }
|
|
// Only add once
|
|
let alreadyAdded = window.gestureRecognizers?.contains(where: { $0 is KeyboardDismissTapRecognizer }) ?? false
|
|
if !alreadyAdded {
|
|
let tap = KeyboardDismissTapRecognizer(
|
|
target: KeyboardDismissible.target,
|
|
action: #selector(KeyboardDismissTarget.dismiss)
|
|
)
|
|
window.addGestureRecognizer(tap)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func dismissKeyboardOnTap() -> some View {
|
|
modifier(KeyboardDismissible())
|
|
}
|
|
}
|