Created
January 23, 2026 08:41
-
-
Save hasanalisiseci/489bf2de754c94a325bd40d09aa268c5 to your computer and use it in GitHub Desktop.
Persistent Bottom Sheet like a Apple Maps App
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // 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