iOS: natural swipe-to-expand/dismiss player with live drag tracking
Some checks failed
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 10s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped

This commit is contained in:
Admin
2026-03-07 23:07:25 +05:00
parent 603cd2bb02
commit 0745178d9e
2 changed files with 75 additions and 13 deletions

View File

@@ -9,6 +9,9 @@ struct RootTabView: View {
@State private var selectedTab: Tab = .home @State private var selectedTab: Tab = .home
@State private var showFullPlayer: Bool = false @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 { enum Tab: Hashable {
case home, library, browse, profile case home, library, browse, profile
} }
@@ -43,17 +46,52 @@ struct RootTabView: View {
} }
} }
// Mini-player pinned above the tab bar // Mini-player pinned above the tab bar (hidden while full player is open)
if audioPlayer.isActive { if audioPlayer.isActive && !showFullPlayer {
MiniPlayerView(showFullPlayer: $showFullPlayer) MiniPlayerView(showFullPlayer: $showFullPlayer)
.padding(.bottom, tabBarHeight) .padding(.bottom, tabBarHeight)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) .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) { .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
FullPlayerView()
}
} }
// Approximate safe-area-aware tab bar height // Approximate safe-area-aware tab bar height

View File

@@ -7,6 +7,9 @@ struct MiniPlayerView: View {
@Binding var showFullPlayer: Bool @Binding var showFullPlayer: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService @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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Progress line thin amber strip at the very top // Progress line thin amber strip at the very top
@@ -81,14 +84,27 @@ struct MiniPlayerView: View {
} }
.background(.ultraThinMaterial) .background(.ultraThinMaterial)
.shadow(color: .black.opacity(0.15), radius: 6, y: -2) .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( .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 .onEnded { value in
if value.translation.height < -20 { let velocity = value.predictedEndTranslation.height - value.translation.height
// Swipe up expand full player withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
dragOffset = 0
}
if value.translation.height < -40 || velocity < -200 {
showFullPlayer = true showFullPlayer = true
} else if value.translation.height > 20 { } else if value.translation.height > 40 || velocity > 200 {
// Swipe down dismiss
audioPlayer.stop() audioPlayer.stop()
} }
} }
@@ -112,7 +128,8 @@ struct MiniPlayerView: View {
struct FullPlayerView: View { struct FullPlayerView: View {
@EnvironmentObject var audioPlayer: AudioPlayerService @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 { var body: some View {
ZStack { ZStack {
@@ -131,6 +148,13 @@ struct FullPlayerView: View {
// Content // Content
VStack(spacing: 0) { 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 // Navigation bar row
HStack { HStack {
Spacer() Spacer()
@@ -139,7 +163,7 @@ struct FullPlayerView: View {
.foregroundStyle(.white) .foregroundStyle(.white)
Spacer() Spacer()
Button { Button {
dismiss() onDismiss()
} label: { } label: {
Text("Done") Text("Done")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
@@ -150,7 +174,7 @@ struct FullPlayerView: View {
} }
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.top, 20) .padding(.top, 8)
Spacer(minLength: 0) Spacer(minLength: 0)