From 0745178d9e50a6817b44979202bee3154c2f0259 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 7 Mar 2026 23:07:25 +0500 Subject: [PATCH] iOS: natural swipe-to-expand/dismiss player with live drag tracking --- ios/LibNovel/LibNovel/App/RootTabView.swift | 48 +++++++++++++++++-- .../LibNovel/Views/Player/PlayerViews.swift | 40 ++++++++++++---- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift index f1b2308..37143d0 100644 --- a/ios/LibNovel/LibNovel/App/RootTabView.swift +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -9,6 +9,9 @@ struct RootTabView: View { @State private var selectedTab: Tab = .home @State private var showFullPlayer: Bool = false + /// Live drag offset while the user is dragging the full player down. + @State private var fullPlayerDragOffset: CGFloat = 0 + enum Tab: Hashable { case home, library, browse, profile } @@ -43,17 +46,52 @@ struct RootTabView: View { } } - // Mini-player pinned above the tab bar - if audioPlayer.isActive { + // Mini-player pinned above the tab bar (hidden while full player is open) + if audioPlayer.isActive && !showFullPlayer { MiniPlayerView(showFullPlayer: $showFullPlayer) .padding(.bottom, tabBarHeight) .transition(.move(edge: .bottom).combined(with: .opacity)) .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) } + + // Full player — slides up from the bottom as a custom overlay (not a sheet) + // so it feels physically connected to the mini player bar. + if showFullPlayer { + FullPlayerView(onDismiss: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + fullPlayerDragOffset = 0 + } + }) + .offset(y: max(fullPlayerDragOffset, 0)) + .gesture( + DragGesture(minimumDistance: 10) + .onChanged { value in + if value.translation.height > 0 { + // Rubberband slightly so it doesn't feel locked + fullPlayerDragOffset = value.translation.height + } + } + .onEnded { value in + let velocity = value.predictedEndTranslation.height - value.translation.height + if value.translation.height > 120 || velocity > 400 { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + fullPlayerDragOffset = 0 + } + } else { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + fullPlayerDragOffset = 0 + } + } + } + ) + .transition(.move(edge: .bottom)) + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) + .ignoresSafeArea() + } } - .sheet(isPresented: $showFullPlayer) { - FullPlayerView() - } + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) } // Approximate safe-area-aware tab bar height diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index 50d706c..1d58932 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -7,6 +7,9 @@ struct MiniPlayerView: View { @Binding var showFullPlayer: Bool @EnvironmentObject var audioPlayer: AudioPlayerService + /// Live drag offset while the user is swiping up (negative = moving up). + @State private var dragOffset: CGFloat = 0 + var body: some View { VStack(spacing: 0) { // Progress line — thin amber strip at the very top @@ -81,14 +84,27 @@ struct MiniPlayerView: View { } .background(.ultraThinMaterial) .shadow(color: .black.opacity(0.15), radius: 6, y: -2) + // Follow finger upward while dragging, spring back or expand on release + .offset(y: min(dragOffset, 0)) .gesture( - DragGesture(minimumDistance: 20, coordinateSpace: .local) + DragGesture(minimumDistance: 10, coordinateSpace: .local) + .onChanged { value in + // Only track upward swipes here; downward dismiss is on ended + if value.translation.height < 0 { + // Rubberband: feels lighter as you drag further + dragOffset = value.translation.height * 0.4 + } else { + dragOffset = value.translation.height * 0.15 + } + } .onEnded { value in - if value.translation.height < -20 { - // Swipe up → expand full player + let velocity = value.predictedEndTranslation.height - value.translation.height + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + dragOffset = 0 + } + if value.translation.height < -40 || velocity < -200 { showFullPlayer = true - } else if value.translation.height > 20 { - // Swipe down → dismiss + } else if value.translation.height > 40 || velocity > 200 { audioPlayer.stop() } } @@ -112,7 +128,8 @@ struct MiniPlayerView: View { struct FullPlayerView: View { @EnvironmentObject var audioPlayer: AudioPlayerService - @Environment(\.dismiss) private var dismiss + /// Called when the view wants to close itself (Done button or drag-to-dismiss). + var onDismiss: () -> Void = {} var body: some View { ZStack { @@ -131,6 +148,13 @@ struct FullPlayerView: View { // ── Content ──────────────────────────────────────────────────── VStack(spacing: 0) { + // Drag handle pill — visual cue that you can swipe down to close + Capsule() + .fill(Color.white.opacity(0.35)) + .frame(width: 36, height: 4) + .padding(.top, 12) + .padding(.bottom, 4) + // Navigation bar row HStack { Spacer() @@ -139,7 +163,7 @@ struct FullPlayerView: View { .foregroundStyle(.white) Spacer() Button { - dismiss() + onDismiss() } label: { Text("Done") .font(.subheadline.weight(.semibold)) @@ -150,7 +174,7 @@ struct FullPlayerView: View { } } .padding(.horizontal, 24) - .padding(.top, 20) + .padding(.top, 8) Spacer(minLength: 0)