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
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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user