Skip to content

Instantly share code, notes, and snippets.

@hasanalisiseci
Created January 23, 2026 08:41
Show Gist options
  • Select an option

  • Save hasanalisiseci/489bf2de754c94a325bd40d09aa268c5 to your computer and use it in GitHub Desktop.

Select an option

Save hasanalisiseci/489bf2de754c94a325bd40d09aa268c5 to your computer and use it in GitHub Desktop.
Persistent Bottom Sheet like a Apple Maps App
//
// PersistentBottomSheet.swift
// MapsIOS26BottomSheet
//
// Created by Hasan Ali on 23/01/26.
//
import SwiftUI
import MapKit
struct PersistentBottomSheet: View {
// MARK: - Properties
@State private var showBottomSheet = true
@State private var sheetDetent: PresentationDetent = .height(80)
@State private var sheetHeight: CGFloat = 0
@State private var animationDuration: CGFloat = 0
@State private var toolbarOpacity: CGFloat = 1
@State private var safeAreaBottomInset: CGFloat = 0
// MARK: - Body
var body: some View {
Map(initialPosition: .region(.sivas))
.safeAreaInset(edge: .bottom, spacing: 0) {
Color.clear
.frame(height: 80)
}
.sheet(isPresented: $showBottomSheet) {
configuredBottomSheet
}
.overlay(alignment: .bottomTrailing) {
floatingToolbar
}
.overlay(alignment: .topLeading) {
weatherView
}
.onGeometryChange(for: CGFloat.self) {
$0.safeAreaInsets.bottom
} action: { newValue in
safeAreaBottomInset = newValue
}
}
// MARK: - Bottom Sheet Configuration
private var configuredBottomSheet: some View {
BottomSheetView(sheetDetent: $sheetDetent)
.presentationDetents([.height(80), .height(350), .large], selection: $sheetDetent)
.presentationBackgroundInteraction(.enabled)
.presentationCornerRadius(isIOS26 ? nil : 30)
.presentationBackground {
if !isIOS26 {
Color.clear
.background(.ultraThinMaterial)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onGeometryChange(for: CGFloat.self) {
max(min($0.size.height, 400 + safeAreaBottomInset), 0)
} action: { oldValue, newValue in
updateSheetMetrics(oldValue: oldValue, newValue: newValue)
}
.ignoresSafeArea()
.interactiveDismissDisabled()
}
// MARK: - Floating Toolbar
private var floatingToolbar: some View {
VStack(spacing: 35) {
Button {
// Handle car action
} label: {
Image(systemName: "car.fill")
}
Button {
// Handle location action
} label: {
Image(systemName: "location")
}
}
.font(.title3)
.foregroundStyle(Color.primary)
.padding(.vertical, 20)
.padding(.horizontal, 10)
.modifier(GlassBackgroundModifier(isIOS26: isIOS26, shape: .capsule))
.opacity(toolbarOpacity)
.offset(y: -sheetHeight)
.animation(sheetAnimation, value: sheetHeight)
.animation(sheetAnimation, value: toolbarOpacity)
.padding(.trailing, 15)
.offset(y: safeAreaBottomInset - 10)
}
// MARK: - Weather View
private var weatherView: some View {
HStack(spacing: 2) {
Image(systemName: "cloud.fill")
Text("28°")
}
.padding(8)
.modifier(GlassBackgroundModifier(isIOS26: isIOS26, shape: .rect(cornerRadius: 12)))
.padding([.leading, .top], 15)
.opacity(toolbarOpacity)
.animation(sheetAnimation, value: toolbarOpacity)
}
// MARK: - Helper Methods
private func updateSheetMetrics(oldValue: CGFloat, newValue: CGFloat) {
sheetHeight = min(newValue, 350 + safeAreaBottomInset)
let progress = max(min((newValue - (350 + safeAreaBottomInset)) / 50, 1), 0)
toolbarOpacity = 1 - progress
let diff = abs(newValue - oldValue)
let duration = max(min(diff / 100, maxAnimationDuration), 0)
animationDuration = duration
}
// MARK: - Computed Properties
private var isIOS26: Bool {
if #available(iOS 26, *) {
return true
}
return false
}
private var maxAnimationDuration: CGFloat {
isIOS26 ? 0.25 : 0.18
}
private var sheetAnimation: Animation {
.interpolatingSpring(duration: animationDuration, bounce: 0, initialVelocity: 0)
}
}
// MARK: - Glass Background Modifier
struct GlassBackgroundModifier: ViewModifier {
let isIOS26: Bool
let shape: AnyShape
init(isIOS26: Bool, shape: some InsettableShape) {
self.isIOS26 = isIOS26
self.shape = AnyShape(shape)
}
func body(content: Content) -> some View {
if #available(iOS 26, *), isIOS26 {
content
.glassEffect(.regular, in: shape)
} else {
content
.background(.ultraThinMaterial, in: shape)
}
}
}
// MARK: - Coordinate Region Extension
extension MKCoordinateRegion {
static let sivas = MKCoordinateRegion(
center: .init(latitude: 39.7477, longitude: 37.0179),
latitudinalMeters: 3000,
longitudinalMeters: 3000
)
}
// MARK: - Preview
#Preview {
PersistentBottomSheet()
}
struct BottomSheetView: View {
@Binding var sheetDetent: PresentationDetent
@State private var searchText: String = ""
@FocusState var isFocused: Bool
var body: some View {
ScrollView(.vertical) {
}
.safeAreaInset(edge: .top, spacing: 0) {
HStack(spacing: 10) {
TextField("Search...", text: $searchText)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.gray.opacity(0.25), in: .capsule)
.focused($isFocused)
Button {
if isFocused {
isFocused = false
} else {
/// Profile Button Action
}
} label: {
Text("BV")
.font(.title2.bold())
.frame(width: 48, height: 48)
.foregroundStyle(.white)
.background(.gray, in: .circle)
.transition(.blurReplace)
}
}
.padding(.horizontal, 18)
.frame(height: 80)
.padding(.top, 5)
}
/// Animating Focus Changes
.animation(
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0),
value: isFocused
)
/// Updating Sheet size when textfield is active
.onChange(of: isFocused) { oldValue, newValue in
sheetDetent = newValue ? .large : .height(350)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment