Repository: Wouter125/BottomSheet Branch: main Commit: 83f016b6ad8e Files: 37 Total size: 61.0 KB Directory structure: gitextract_vhrt5p88/ ├── .gitignore ├── Example/ │ ├── BottomSheetExample/ │ │ ├── Apple Applications/ │ │ │ └── StocksExample.swift │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── BottomSheetExampleApp.swift │ │ ├── ExampleOverview.swift │ │ ├── Examples/ │ │ │ └── StaticScrollViewExample.swift │ │ ├── Preview Content/ │ │ │ └── Preview Assets.xcassets/ │ │ │ └── Contents.json │ │ └── View Modifiers/ │ │ └── CornerRadius.swift │ └── BottomSheetExample.xcodeproj/ │ ├── project.pbxproj │ ├── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata/ │ └── xcschemes/ │ └── BottomSheetExample.xcscheme ├── LICENSE ├── Package.swift ├── README.md ├── Sources/ │ └── BottomSheet/ │ ├── Animation/ │ │ ├── Animation.swift │ │ └── AnimationDefaults.swift │ ├── BottomSheet.swift │ ├── Detents/ │ │ ├── DetentDefaults.swift │ │ ├── DetentHelpers.swift │ │ └── Detents.swift │ ├── Helpers/ │ │ ├── KeyboardReader.swift │ │ └── Snapping.swift │ ├── Preference Keys/ │ │ ├── BackgroundInteractionKey.swift │ │ ├── ConfigKey.swift │ │ └── IndicatorKey.swift │ ├── UIKit Views/ │ │ └── UIScrollViewWrapper.swift │ ├── View Modifiers/ │ │ ├── View+AnimationChange.swift │ │ ├── View+BackgroundInteraction.swift │ │ ├── View+Detents.swift │ │ ├── View+DragIndicator.swift │ │ └── View+SheetPlus.swift │ └── Views/ │ └── DragIndicator.swift └── Tests/ └── BottomSheetTests/ └── BottomSheetTests.swift ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .DS_Store /.build /Packages /*.xcodeproj xcuserdata/ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata ================================================ FILE: Example/BottomSheetExample/Apple Applications/StocksExample.swift ================================================ // // Stocks.swift // BottomSheetExample // // Created by Wouter van de Kamp on 04/12/2022. // import SwiftUI import BottomSheet struct StocksExample: View { @EnvironmentObject var settings: SheetSettings var body: some View { VStack { Button("Close") { settings.isPresented.toggle() } Button("Change") { settings.selectedDetent = .large } Color.clear .navigationBarTitleDisplayMode(.inline) .navigationTitle("\(settings.translation.rounded())") .onAppear { settings.isPresented = true settings.activeSheetType = .stocks } } } } struct StocksHeader: View { var body: some View { VStack { HStack { VStack(alignment: .leading, spacing: 2) { Text("Business News") .font(.title) .fontWeight(.heavy) Text("From Yahoo Finance") .foregroundColor(Color(UIColor.secondaryLabel)) } .padding(.top, 10) .padding(.bottom, 16) Spacer() } Divider() .frame(height: 1) .background(Color(UIColor.systemGray6)) } .padding(.top, 8) .padding(.horizontal, 16) } } struct StocksMainContent: View { var body: some View { VStack(spacing: 0) { ScrollView { ForEach(0..<5, id: \.self) { _ in newsRow } } Spacer(minLength: 72) } } var newsRow: some View { VStack { HStack { VStack(alignment: .leading, spacing: 4) { Text("FX Empire") .font(.caption) .foregroundColor(Color(UIColor.secondaryLabel)) Text("Bitcoin (BTC) treads water after brief visit to sub-$39,000") .font(.headline) .foregroundColor(Color(UIColor.label)) Text("While Bitcoin (BTC) struggled on Saturday, XRP") .foregroundColor(Color(UIColor.secondaryLabel)) .lineLimit(1) } .padding(.vertical, 16) Spacer() } HStack { Text("13h ago") .font(.caption) .fontWeight(.medium) .foregroundColor(Color(UIColor.secondaryLabel)) Spacer() } } .padding(.horizontal, 16) } } struct StocksExample_Previews: PreviewProvider { static var previews: some View { StocksExample() } } ================================================ FILE: Example/BottomSheetExample/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Example/BottomSheetExample/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Example/BottomSheetExample/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Example/BottomSheetExample/BottomSheetExampleApp.swift ================================================ // // BottomSheetExampleApp.swift // BottomSheetExample // // Created by Wouter van de Kamp on 10/03/2022. // import SwiftUI @main struct BottomSheetExampleApp: App { var body: some Scene { WindowGroup { ExampleOverview() } } } ================================================ FILE: Example/BottomSheetExample/ExampleOverview.swift ================================================ // // ExampleOverview.swift // BottomSheetExample // // Created by Wouter van de Kamp on 24/05/2022. // import SwiftUI import BottomSheet enum SheetExampleTypes { case home case stocks case staticScrollView } class SheetSettings: ObservableObject { @Published var isPresented = false @Published var activeSheetType: SheetExampleTypes = .home @Published var selectedDetent: BottomSheet.PresentationDetent = .medium @Published var translation: CGFloat = BottomSheet.PresentationDetent.large.size } struct ExampleOverview: View { @StateObject var settings = SheetSettings() var views: [(label: String, view: AnyView)] = [ (label: "Stocks example", view: AnyView(StocksExample())), (label: "Static scrollview example", view: AnyView(StaticScrollViewExample())) ] @ViewBuilder var headerContent: some View { switch settings.activeSheetType { case .stocks: StocksHeader() case .staticScrollView: StaticScrollViewHeader() default: EmptyView() } } @ViewBuilder var mainContent: some View { switch settings.activeSheetType { case .stocks: StocksMainContent() .presentationDetentsPlus( [.height(244), .medium, .large], selection: $settings.selectedDetent ) case .staticScrollView: StaticScrollViewContent() .presentationDetentsPlus( [.height(244), .height(380), .height(480), .large], selection: $settings.selectedDetent ) .presentationDragIndicatorPlus(.visible) .presentationBackgroundInteractionPlus(.enabled(upThrough: .height(380))) default: EmptyView() } } var body: some View { ZStack { NavigationView { List(views.indices, id: \.self) { index in NavigationLink(destination: views[index].view) { Text(views[index].label) } } .background(Color(UIColor.systemGroupedBackground)) .listStyle(.grouped) .navigationTitle("Examples") .onAppear { settings.isPresented = false settings.activeSheetType = .home settings.selectedDetent = .medium } } .navigationViewStyle(.stack) } .environmentObject(settings) .sheetPlus( isPresented: $settings.isPresented, background: ( Color(UIColor.secondarySystemBackground) .cornerRadius(12, corners: [.topLeft, .topRight]) ), onDrag: { translation in settings.translation = translation }, header: { headerContent }, main: { mainContent } ) .overlay( VStack(spacing: 0) { Divider() .frame(height: 1) .background(Color(UIColor.systemGray6)) HStack { Text("Yahoo Finance") Spacer() } .padding(.horizontal, 16) .padding(.vertical, 16) } .background( Color(UIColor.secondarySystemBackground) .edgesIgnoringSafeArea([.bottom]) ) .opacity( settings.activeSheetType == .stocks ? 1 : 0 ) , alignment: .bottom ) } } struct ExampleOverview_Previews: PreviewProvider { static var previews: some View { ExampleOverview() } } ================================================ FILE: Example/BottomSheetExample/Examples/StaticScrollViewExample.swift ================================================ // // StaticScrollViewExample.swift // BottomSheetExample // // Created by Wouter van de Kamp on 28/10/2023. // import SwiftUI struct StaticScrollViewExample: View { @EnvironmentObject var settings: SheetSettings var body: some View { VStack { Button("Close") { settings.isPresented.toggle() } Button("Change") { settings.selectedDetent = .large } Color.clear .navigationBarTitleDisplayMode(.inline) .navigationTitle("\(settings.translation.rounded())") .onAppear { settings.isPresented = true settings.activeSheetType = .staticScrollView } } } } struct StaticScrollViewHeader: View { @State private var searchterm = "" var body: some View { VStack { TextField("Search item", text: $searchterm) } } } struct StaticScrollViewContent: View { var body: some View { ScrollView { ForEach(0..<5, id: \.self) { idx in Text("Item \(idx)") } } } } struct StaticScrollViewExample_Previews: PreviewProvider { static var previews: some View { StaticScrollViewExample() } } ================================================ FILE: Example/BottomSheetExample/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Example/BottomSheetExample/View Modifiers/CornerRadius.swift ================================================ // // CornerRadius.swift // BottomSheetExample // // Created by Wouter van de Kamp on 25/03/2022. // import SwiftUI struct CornerRadiusStyle: ViewModifier { var radius: CGFloat var corners: UIRectCorner struct CornerRadiusShape: Shape { var radius = CGFloat.infinity var corners = UIRectCorner.allCorners func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius) ) return Path(path.cgPath) } } func body(content: Content) -> some View { content .clipShape(CornerRadiusShape(radius: radius, corners: corners)) } } extension View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { ModifiedContent(content: self, modifier: CornerRadiusStyle(radius: radius, corners: corners)) } } ================================================ FILE: Example/BottomSheetExample.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 55; objects = { /* Begin PBXBuildFile section */ 6C0BD46D27DA862D000AF3CD /* BottomSheetExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0BD46C27DA862D000AF3CD /* BottomSheetExampleApp.swift */; }; 6C0BD47127DA862D000AF3CD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6C0BD47027DA862D000AF3CD /* Assets.xcassets */; }; 6C0BD47427DA862D000AF3CD /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6C0BD47327DA862D000AF3CD /* Preview Assets.xcassets */; }; 6C6EB0B6292AEADC00106A1D /* BottomSheet in Frameworks */ = {isa = PBXBuildFile; productRef = 6C6EB0B5292AEADC00106A1D /* BottomSheet */; }; 6C763147283D774500463709 /* ExampleOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C763146283D774500463709 /* ExampleOverview.swift */; }; 6C8CBF1D2AED12E00007E10E /* StaticScrollViewExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8CBF1C2AED12E00007E10E /* StaticScrollViewExample.swift */; }; 6CDF5A0D27ED33C7004609F4 /* CornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDF5A0C27ED33C7004609F4 /* CornerRadius.swift */; }; 6CF78515293D36FB000E6581 /* StocksExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF78514293D36FB000E6581 /* StocksExample.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 6C0BD46927DA862D000AF3CD /* BottomSheetExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BottomSheetExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6C0BD46C27DA862D000AF3CD /* BottomSheetExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetExampleApp.swift; sourceTree = ""; }; 6C0BD47027DA862D000AF3CD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 6C0BD47327DA862D000AF3CD /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 6C1DE0102889CF10003C6EE9 /* BottomSheet */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = BottomSheet; path = ..; sourceTree = ""; }; 6C763146283D774500463709 /* ExampleOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleOverview.swift; sourceTree = ""; }; 6C8CBF1C2AED12E00007E10E /* StaticScrollViewExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticScrollViewExample.swift; sourceTree = ""; }; 6CDF5A0C27ED33C7004609F4 /* CornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerRadius.swift; sourceTree = ""; }; 6CF78514293D36FB000E6581 /* StocksExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StocksExample.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 6C0BD46627DA862D000AF3CD /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6C6EB0B6292AEADC00106A1D /* BottomSheet in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 6C0BD46027DA862C000AF3CD = { isa = PBXGroup; children = ( 6C1DE0102889CF10003C6EE9 /* BottomSheet */, 6C0BD46B27DA862D000AF3CD /* BottomSheetExample */, 6C0BD46A27DA862D000AF3CD /* Products */, 6C0BD47A27DA87A1000AF3CD /* Frameworks */, ); sourceTree = ""; }; 6C0BD46A27DA862D000AF3CD /* Products */ = { isa = PBXGroup; children = ( 6C0BD46927DA862D000AF3CD /* BottomSheetExample.app */, ); name = Products; sourceTree = ""; }; 6C0BD46B27DA862D000AF3CD /* BottomSheetExample */ = { isa = PBXGroup; children = ( 6C0BD46C27DA862D000AF3CD /* BottomSheetExampleApp.swift */, 6C763146283D774500463709 /* ExampleOverview.swift */, 6CF78516293D3703000E6581 /* Apple Applications */, 6C8CBF1B2AED12C60007E10E /* Examples */, 6CDF5A0E27ED33D3004609F4 /* View Modifiers */, 6C0BD47027DA862D000AF3CD /* Assets.xcassets */, 6C0BD47227DA862D000AF3CD /* Preview Content */, ); path = BottomSheetExample; sourceTree = ""; }; 6C0BD47227DA862D000AF3CD /* Preview Content */ = { isa = PBXGroup; children = ( 6C0BD47327DA862D000AF3CD /* Preview Assets.xcassets */, ); path = "Preview Content"; sourceTree = ""; }; 6C0BD47A27DA87A1000AF3CD /* Frameworks */ = { isa = PBXGroup; children = ( ); name = Frameworks; sourceTree = ""; }; 6C8CBF1B2AED12C60007E10E /* Examples */ = { isa = PBXGroup; children = ( 6C8CBF1C2AED12E00007E10E /* StaticScrollViewExample.swift */, ); path = Examples; sourceTree = ""; }; 6CDF5A0E27ED33D3004609F4 /* View Modifiers */ = { isa = PBXGroup; children = ( 6CDF5A0C27ED33C7004609F4 /* CornerRadius.swift */, ); path = "View Modifiers"; sourceTree = ""; }; 6CF78516293D3703000E6581 /* Apple Applications */ = { isa = PBXGroup; children = ( 6CF78514293D36FB000E6581 /* StocksExample.swift */, ); path = "Apple Applications"; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 6C0BD46827DA862D000AF3CD /* BottomSheetExample */ = { isa = PBXNativeTarget; buildConfigurationList = 6C0BD47727DA862D000AF3CD /* Build configuration list for PBXNativeTarget "BottomSheetExample" */; buildPhases = ( 6C0BD46527DA862D000AF3CD /* Sources */, 6C0BD46627DA862D000AF3CD /* Frameworks */, 6C939DBE294DFF9200F6EF50 /* Swiftlint */, 6C0BD46727DA862D000AF3CD /* Resources */, ); buildRules = ( ); dependencies = ( ); name = BottomSheetExample; packageProductDependencies = ( 6C6EB0B5292AEADC00106A1D /* BottomSheet */, ); productName = BottomSheetExample; productReference = 6C0BD46927DA862D000AF3CD /* BottomSheetExample.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 6C0BD46127DA862C000AF3CD /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1320; LastUpgradeCheck = 1320; TargetAttributes = { 6C0BD46827DA862D000AF3CD = { CreatedOnToolsVersion = 13.2.1; }; }; }; buildConfigurationList = 6C0BD46427DA862C000AF3CD /* Build configuration list for PBXProject "BottomSheetExample" */; compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 6C0BD46027DA862C000AF3CD; packageReferences = ( ); productRefGroup = 6C0BD46A27DA862D000AF3CD /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 6C0BD46827DA862D000AF3CD /* BottomSheetExample */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 6C0BD46727DA862D000AF3CD /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 6C0BD47427DA862D000AF3CD /* Preview Assets.xcassets in Resources */, 6C0BD47127DA862D000AF3CD /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 6C939DBE294DFF9200F6EF50 /* Swiftlint */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( ); name = Swiftlint; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\nif which swiftlint > /dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 6C0BD46527DA862D000AF3CD /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6C0BD46D27DA862D000AF3CD /* BottomSheetExampleApp.swift in Sources */, 6C8CBF1D2AED12E00007E10E /* StaticScrollViewExample.swift in Sources */, 6CDF5A0D27ED33C7004609F4 /* CornerRadius.swift in Sources */, 6C763147283D774500463709 /* ExampleOverview.swift in Sources */, 6CF78515293D36FB000E6581 /* StocksExample.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ 6C0BD47527DA862D000AF3CD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.2; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 6C0BD47627DA862D000AF3CD /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.2; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; 6C0BD47827DA862D000AF3CD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "BottomSheetExample/Preview\\ Content"; DEVELOPMENT_TEAM = KZAMEFAGHT; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.chargetrip.BottomSheetExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 6C0BD47927DA862D000AF3CD /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "BottomSheetExample/Preview\\ Content"; DEVELOPMENT_TEAM = KZAMEFAGHT; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.chargetrip.BottomSheetExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 6C0BD46427DA862C000AF3CD /* Build configuration list for PBXProject "BottomSheetExample" */ = { isa = XCConfigurationList; buildConfigurations = ( 6C0BD47527DA862D000AF3CD /* Debug */, 6C0BD47627DA862D000AF3CD /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6C0BD47727DA862D000AF3CD /* Build configuration list for PBXNativeTarget "BottomSheetExample" */ = { isa = XCConfigurationList; buildConfigurations = ( 6C0BD47827DA862D000AF3CD /* Debug */, 6C0BD47927DA862D000AF3CD /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ 6C6EB0B5292AEADC00106A1D /* BottomSheet */ = { isa = XCSwiftPackageProductDependency; productName = BottomSheet; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6C0BD46127DA862C000AF3CD /* Project object */; } ================================================ FILE: Example/BottomSheetExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: Example/BottomSheetExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: Example/BottomSheetExample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: Example/BottomSheetExample.xcodeproj/xcshareddata/xcschemes/BottomSheetExample.xcscheme ================================================ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Wouter van de Kamp. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Package.swift ================================================ // swift-tools-version:5.5 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "BottomSheet", platforms: [ .iOS(.v14) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "BottomSheet", targets: ["BottomSheet"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "BottomSheet", dependencies: []), .testTarget( name: "BottomSheetTests", dependencies: ["BottomSheet"]), ] ) ================================================ FILE: README.md ================================================ # BottomSheet An iOS library for SwiftUI to create draggable sheet experiences similar to iOS applications like Maps and Stocks. ## Feature overview The library currently supports; - [x] Unlimited snap positions - [x] Realtime position callback - [x] Absolute and relative positioning - [x] Customizable animation parameters - [x] An optional sticky header - [x] Views with and without a scrollview ## How to install Currently BottomSheet is only available through the [Swift Package Manager](https://swift.org/package-manager/) or manual install. 1. Installation through Swift Package Manager can be done by going to `File > Add Packages`. Then enter the following URL in the searchbar; `https://github.com/Wouter125/BottomSheet`. 2. Manual installation can be done by cloning this repository and dragging all assets into your Xcode Project. ## How to use 1. Import BottomSheet 2. Create a state property that contains the presentation state of the bottom sheet and one for the current selection; ``` @Published var isPresented = false @Published var selectedDetent: BottomSheet.PresentationDetent = .medium ``` 4. Add the `BottomSheetView` to your SwiftUI view hierachy by using a view modifier; ``` .sheetPlus( isPresented: $isPresented, header: { }, main: { EmptyView() .presentationDetentsPlus( [.height(244), .fraction(0.4), .medium, .large], selection: $selectedDetent ) } ) ``` 5. Optionally receive the current panel position with a callback, change the background color, show a drag indicator or limit the background interaction based on the height; ``` .sheetPlus( isPresented: $isPresented, background: ( Color(UIColor.secondarySystemBackground) ), onDrag: { translation in print(translation) }, header: { EmptyView() }, main: { EmptyView() .presentationDetentsPlus( [.height(244), .fraction(0.4), .medium, .large], selection: $selectedDetent ) .presentationDragIndicatorPlus(.visible) .presentationBackgroundInteractionPlus(.enabled(upThrough: .height(380))) } ) ``` ## Interface | Modifier | Type | Default | Description | |--------------------------|---------------------|---------|-----------------------------------------------------------------------------------| | animationCurve.mass | Double | 1.2 | The mass of the object attached to the spring. | | animationCurve.stiffness | Double | 200 | The stiffness of the spring. | | animationCurve.damping | Double | 25 | The spring damping value. | ## Example To give you an idea of how to use this library you can use the example that is attached to this repo. Simply clone it and open the `BottomSheetExample` folder in Xcode. ## Roadmap 1. Add landscape support 2. Add iPad support ================================================ FILE: Sources/BottomSheet/Animation/Animation.swift ================================================ // // Animation.swift // // // Created by Wouter van de Kamp on 26/11/2022. // import Foundation public struct SheetAnimation { var mass: Double var stiffness: Double var damping: Double public init(mass: Double, stiffness: Double, damping: Double) { self.mass = mass self.stiffness = stiffness self.damping = damping } } ================================================ FILE: Sources/BottomSheet/Animation/AnimationDefaults.swift ================================================ // // AnimationDefaults.swift // // // Created by Wouter van de Kamp on 26/11/2022. // import Foundation public struct SheetAnimationDefaults { public static let mass: Double = 1.2 public static let stiffness: Double = 200 public static let damping: Double = 25 } ================================================ FILE: Sources/BottomSheet/BottomSheet.swift ================================================ // // BottomSheet.swift // // // Created by Wouter van de Kamp on 26/11/2022. // import SwiftUI struct SheetPlus: ViewModifier, KeyboardReader { @Binding private var isPresented: Bool @State private var translation: CGFloat = 0 @State private var sheetConfig: SheetPlusConfig? @State private var showDragIndicator: VisibilityPlus? @State private var allowBackgroundInteraction: PresentationBackgroundInteractionPlus? @State private var newValue = 0.0 @State private var startTime: DragGesture.Value? @State private var detents: Set = [] @State private var limits: (min: CGFloat, max: CGFloat) = (min: 0, max: 0) let mainContent: MContent let headerContent: HContent let animationCurve: SheetAnimation let onDismiss: () -> Void let onDrag: (CGFloat) -> Void let background: Background init( isPresented: Binding, animationCurve: SheetAnimation, background: Background, onDismiss: @escaping () -> Void, onDrag: @escaping (CGFloat) -> Void, @ViewBuilder hcontent: () -> HContent, @ViewBuilder mcontent: () -> MContent ) { self._isPresented = isPresented self.animationCurve = animationCurve self.background = background self.onDismiss = onDismiss self.onDrag = onDrag self.headerContent = hcontent() self.mainContent = mcontent() } func body(content: Content) -> some View { ZStack() { content .allowsHitTesting(allowBackgroundInteraction == .disabled ? false : true) if isPresented { GeometryReader { geometry in VStack(spacing: 0) { // If / else statement here breaks the animation from the bottom level // Might want to see if we can refactor the top level animation a bit DragIndicator( translation: $translation, detents: detents ) .frame(height: showDragIndicator == .visible ? 22 : 0) .opacity(showDragIndicator == .visible ? 1 : 0) headerContent .contentShape(Rectangle()) .gesture( DragGesture(coordinateSpace: .global) .onChanged { value in translation -= value.location.y - value.startLocation.y - newValue newValue = value.location.y - value.startLocation.y if startTime == nil { startTime = value } } .onEnded { value in // Reset the distance on release so we start with a // clean translation next time newValue = 0 // Calculate velocity based on pt/s so it matches the UIPanGesture let distance: CGFloat = value.translation.height let time: CGFloat = value.time.timeIntervalSince(startTime!.time) let yVelocity: CGFloat = -1 * ((distance / time) / 1000) startTime = nil if let result = snapBottomSheet(translation, detents, yVelocity) { translation = result.size sheetConfig?.selectedDetent = result } } ) UIScrollViewWrapper( translation: $translation, preferenceKey: $sheetConfig, limits: limits, detents: detents ) { mainContent .frame(width: geometry.size.width) } } .background(background) .frame(height: (limits.max - geometry.safeAreaInsets.top) > 0 ? limits.max - geometry.safeAreaInsets.top : limits.max ) .onChange(of: translation) { newValue in // Small little hack to make the iOS scroll behaviour work smoothly if limits.max == 0 { return } translation = min(limits.max, max(newValue, limits.min)) currentGlobalTranslation = translation } .onAnimationChange(of: translation) { value in onDrag(value) } .offset(y: UIScreen.main.bounds.height - translation) .onDisappear { translation = 0 detents = [] onDismiss() } .animation( .interpolatingSpring( mass: animationCurve.mass, stiffness: animationCurve.stiffness, damping: animationCurve.damping ) ) } .edgesIgnoringSafeArea([.bottom]) .transition(.move(edge: .bottom)) } } .onPreferenceChange(SheetPlusKey.self) { value in /// Quick hack to prevent the scrollview from resetting the height when keyboard shows up. /// Replace if the root cause has been located. if value.detents.count == 0 { return } sheetConfig = value translation = value.translation detents = value.detents limits = detentLimits(detents: detents) } .onPreferenceChange(SheetPlusIndicatorKey.self) { value in showDragIndicator = value } .onPreferenceChange(SheetPlusBackgroundInteractionKey.self) { value in allowBackgroundInteraction = value } } } ================================================ FILE: Sources/BottomSheet/Detents/DetentDefaults.swift ================================================ // // DetentsDefaults.swift // // // Created by Wouter van de Kamp on 20/11/2022. // import SwiftUI internal struct PresentationDetentDefaults { static let small: CGFloat = UIScreen.main.bounds.height * 0.2 static let medium: CGFloat = UIScreen.main.bounds.height * 0.5 static let large: CGFloat = UIScreen.main.bounds.height * 0.9 } ================================================ FILE: Sources/BottomSheet/Detents/DetentHelpers.swift ================================================ // // DetentsHelper.swift // // // Created by Wouter van de Kamp on 20/11/2022. // import SwiftUI /// Computes the limits of how far the sheet can move. /// - Parameter detents: The list of detents provided when initializing the bottomsheet /// - Returns: Tuple with top and bottom. Top reflects the offset from the top of the screen when the sheet is in it's largest form. Bottom reflects the offset from the top of the screen when the sheet is in it's smallest form. internal func detentLimits(detents: Set) -> (min: CGFloat, max: CGFloat) { let detentLimits: [CGFloat] = detents .map { detent in switch detent { case .small: return PresentationDetentDefaults.small case .medium: return PresentationDetentDefaults.medium case .large: return PresentationDetentDefaults.large case .fraction(let fraction): return UIScreen.main.bounds.height * fraction case .height(let height): return height } } .sorted(by: <) return (min: detentLimits.first ?? 0, max: detentLimits.last ?? 0) } ================================================ FILE: Sources/BottomSheet/Detents/Detents.swift ================================================ // // Detents.swift // // // Created by Wouter van de Kamp on 20/11/2022. // import SwiftUI public enum PresentationDetent: Hashable { case small case medium case large case fraction(CGFloat) case height(CGFloat) public var size: CGFloat { switch self { case .small: return PresentationDetentDefaults.small case .medium: return PresentationDetentDefaults.medium case .large: return PresentationDetentDefaults.large case .fraction(let fraction): return min( UIScreen.main.bounds.height * fraction, UIScreen.main.bounds.height ) case .height(let height): return min( height, UIScreen.main.bounds.height ) } } } ================================================ FILE: Sources/BottomSheet/Helpers/KeyboardReader.swift ================================================ // // KeyboardReader.swift // // // Created by Wouter van de Kamp on 28/10/2023. // import Combine import UIKit protocol KeyboardReader { var keyboardPublisher: AnyPublisher { get } } extension KeyboardReader { var keyboardPublisher: AnyPublisher { Publishers.Merge( NotificationCenter.default .publisher(for: UIResponder.keyboardWillShowNotification) .map { _ in true }, NotificationCenter.default .publisher(for: UIResponder.keyboardWillHideNotification) .map { _ in false } ) .eraseToAnyPublisher() } } ================================================ FILE: Sources/BottomSheet/Helpers/Snapping.swift ================================================ // // Snapping.swift // // // Created by Wouter van de Kamp on 20/11/2022. // import Foundation /// Helper function that computes where the bottomsheet should snap to /// - Parameters: /// - translation: the current translated distance /// - detents: the detents the translation can snap to /// - yVelocity: the speed at which the drag gesture ended. Used to compute a snapping behaviour /// - Returns: The snapping position distance internal func snapBottomSheet(_ translation: CGFloat, _ detents: Set, _ yVelocity: CGFloat) -> PresentationDetent? { let detents = detents.sorted(by: { $0.size < $1.size }) let position: [PresentationDetent] = detents.enumerated().compactMap { idx, detent in if idx < detents.index(before: detents.count) { let detentBracket = ( lower: detents[idx], middle: detents[idx].size + ((detents[idx + 1].size - detents[idx].size) / 2), upper: detents[idx + 1] ) if detentBracket.lower.size...detentBracket.upper.size ~= translation { if abs(yVelocity) > 1.8 { return yVelocity > 0 ? detentBracket.upper : detentBracket.lower } else { return translation > detentBracket.middle ? detentBracket.upper : detentBracket.lower } } } return nil } return position.first } ================================================ FILE: Sources/BottomSheet/Preference Keys/BackgroundInteractionKey.swift ================================================ // // BackgroundInteractionKey.swift // // // Created by Wouter van de Kamp on 29/10/2023. // import SwiftUI // Currently using a global var. // Might want to rework this by setting up the view modifiers a bit different. // Probably something that we can hold translation in 1 var. Now both need to be in sync. var currentGlobalTranslation: CGFloat = 0 public enum PresentationBackgroundInteractionPlus { case automatic case disabled case enabled public static func enabled(upThrough detent: PresentationDetent) -> PresentationBackgroundInteractionPlus { currentGlobalTranslation > detent.size ? .disabled : .enabled } } struct SheetPlusBackgroundInteractionKey: PreferenceKey { static var defaultValue: PresentationBackgroundInteractionPlus = .automatic static func reduce(value: inout PresentationBackgroundInteractionPlus, nextValue: () -> PresentationBackgroundInteractionPlus) { value = nextValue() } } ================================================ FILE: Sources/BottomSheet/Preference Keys/ConfigKey.swift ================================================ // // ConfigKey.swift // // // Created by Wouter van de Kamp on 20/11/2022. // import SwiftUI struct SheetPlusConfig: Equatable { let detents: Set @Binding var selectedDetent: PresentationDetent let translation: CGFloat static func == (lhs: SheetPlusConfig, rhs: SheetPlusConfig) -> Bool { return lhs.selectedDetent == rhs.selectedDetent && lhs.translation == rhs.translation && lhs.detents == rhs.detents } } struct SheetPlusKey: PreferenceKey { static var defaultValue: SheetPlusConfig = SheetPlusConfig(detents: [], selectedDetent: .constant(.height(.zero)), translation: 0) static func reduce(value: inout SheetPlusConfig, nextValue: () -> SheetPlusConfig) { /// This prevents the translation changes to be called whenever the keyboard is triggered. /// If the keyboard gets triggered it will also reset the whole configkey and losing the binding. /// https://stackoverflow.com/questions/67644164/preferencekey-issue-swiftui-sometimes-seems-to-generate-additional-views-that value = nextValue() != defaultValue ? nextValue() : value } } ================================================ FILE: Sources/BottomSheet/Preference Keys/IndicatorKey.swift ================================================ // // IndicatorKey.swift // // // Created by Wouter van de Kamp on 29/10/2023. // import Foundation import SwiftUI struct SheetPlusIndicatorKey: PreferenceKey { static var defaultValue: VisibilityPlus = .automatic static func reduce(value: inout VisibilityPlus, nextValue: () -> VisibilityPlus) { value = nextValue() } } ================================================ FILE: Sources/BottomSheet/UIKit Views/UIScrollViewWrapper.swift ================================================ // // UIScrollViewWrapper.swift // // // Created by Wouter van de Kamp on 20/11/2022. // import Foundation import SwiftUI import UIKit internal struct UIScrollViewWrapper: UIViewRepresentable { @Binding var translation: CGFloat @Binding var preferenceKey: SheetPlusConfig? let limits: (min: CGFloat, max: CGFloat) let detents: Set let content: () -> Content func makeUIView(context: Context) -> UIScrollView { let scrollView = UIScrollView() let hostingController = context.coordinator.hostingController scrollView.addSubview(hostingController.view) scrollView.contentInsetAdjustmentBehavior = .automatic scrollView.alwaysBounceVertical = true scrollView.delegate = context.coordinator hostingController.view.translatesAutoresizingMaskIntoConstraints = false scrollView.addConstraints([ hostingController.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), hostingController.view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), hostingController.view.topAnchor.constraint(equalTo: scrollView.topAnchor), hostingController.view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor) ]) hostingController.view.backgroundColor = .clear scrollView.backgroundColor = .clear scrollView.layoutIfNeeded() return scrollView } func updateUIView(_ scrollView: UIScrollView, context: Context) { context.coordinator.limits = limits context.coordinator.detents = detents context.coordinator.hostingController.rootView = self.content() } func makeCoordinator() -> Coordinator { return Coordinator( representable: self, hostingController: UIHostingController(rootView: content()), limits: limits, detents: detents ) } class Coordinator: NSObject, UIScrollViewDelegate { private var scrollOffset: CGFloat = 0 private var newValue: CGFloat = 0 var representable: UIScrollViewWrapper var hostingController: UIHostingController var limits: (min: CGFloat, max: CGFloat) var detents: Set init( representable: UIScrollViewWrapper, hostingController: UIHostingController, limits: (min: CGFloat, max: CGFloat), detents: Set ) { self.hostingController = hostingController self.limits = limits self.detents = detents self.representable = representable } private func shouldDragSheet(_ scrollViewPosition: CGFloat, isFixedHeight: Bool) -> Bool { // Translation on a scrollview without an overflow get's set to 0 somehow. // Implemented this check to prevent it from snapping back to the original position. // Need to dive deeper to figure out why it gets set to 0. if isFixedHeight && representable.translation == 0 { if scrollViewPosition > scrollOffset { scrollOffset = scrollViewPosition } return scrollViewPosition < 0 } if representable.translation >= limits.max { if scrollViewPosition > scrollOffset { scrollOffset = scrollViewPosition } return scrollViewPosition < 0 } return true } func scrollViewDidScroll(_ scrollView: UIScrollView) { let isFixedHeight = scrollView.contentSize.height < scrollView.frame.size.height guard scrollView.isTracking else { return } guard shouldDragSheet(scrollView.contentOffset.y, isFixedHeight: isFixedHeight) else { scrollView.showsVerticalScrollIndicator = true return } let localTranslation = scrollView.panGestureRecognizer.translation(in: scrollView.superview).y - scrollOffset let translationDelta = localTranslation - newValue representable.translation -= translationDelta newValue = localTranslation scrollView.showsVerticalScrollIndicator = false scrollView.contentOffset.y = .zero } func scrollViewWillEndDragging( _ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer ) { if representable.translation != limits.max { targetContentOffset.pointee = .zero } if let result = snapBottomSheet( representable.translation, detents, scrollView.contentOffset.y > 0 ? 0 : velocity.y ) { representable.translation = result.size representable.preferenceKey?.selectedDetent = result } scrollOffset = 0 newValue = 0 } } } ================================================ FILE: Sources/BottomSheet/View Modifiers/View+AnimationChange.swift ================================================ // // OnAnimationChange.swift // // // Created by Wouter van de Kamp on 10/03/2022. // import SwiftUI internal struct AnimationObserverModifier: AnimatableModifier where Value: VectorArithmetic { var animatableData: Value { didSet { updateAnimationData() } } private var update: (CGFloat) -> Void init(observedValue: Value, update: @escaping (CGFloat) -> Void) { self.animatableData = observedValue self.update = update } func body(content: Content) -> some View { return content } private func updateAnimationData() { DispatchQueue.main.async { // swiftlint:disable force_cast update(animatableData as! CGFloat) } } } extension View { func onAnimationChange( of value: Value, perform: @escaping (CGFloat) -> Void ) -> ModifiedContent> { return modifier(AnimationObserverModifier(observedValue: value, update: perform)) } } ================================================ FILE: Sources/BottomSheet/View Modifiers/View+BackgroundInteraction.swift ================================================ // // View+BackgroundInteraction.swift // // // Created by Wouter van de Kamp on 29/10/2023. // import SwiftUI extension View { public func presentationBackgroundInteractionPlus( _ interaction: PresentationBackgroundInteractionPlus ) -> some View { return self.preference( key: SheetPlusBackgroundInteractionKey.self, value: interaction ) } } ================================================ FILE: Sources/BottomSheet/View Modifiers/View+Detents.swift ================================================ // // View+Detents.swift // // // Created by Wouter van de Kamp on 02/07/2023. // import SwiftUI extension View { public func presentationDetentsPlus( _ detents: Set ) -> some View { let sortedDetents = Array(detents).sorted(by: { $0.size < $1.size }) return self.preference( key: SheetPlusKey.self, value: SheetPlusConfig( detents: detents, selectedDetent: Binding(get: { sortedDetents.first! }, set: { _ in }), translation: sortedDetents.first!.size ) ) } public func presentationDetentsPlus( _ detents: Set, selection: Binding ) -> some View { return self.preference( key: SheetPlusKey.self, value: SheetPlusConfig( detents: detents, selectedDetent: selection, translation: selection.wrappedValue.size ) ) } } ================================================ FILE: Sources/BottomSheet/View Modifiers/View+DragIndicator.swift ================================================ // // View+DragIndicator.swift // // // Created by Wouter van de Kamp on 29/10/2023. // import SwiftUI public enum VisibilityPlus { case hidden case visible case automatic } extension View { public func presentationDragIndicatorPlus( _ visibility: VisibilityPlus ) -> some View { return self.preference( key: SheetPlusIndicatorKey.self, value: visibility ) } } ================================================ FILE: Sources/BottomSheet/View Modifiers/View+SheetPlus.swift ================================================ // // View+SheetPlus.swift // // // Created by Wouter van de Kamp on 21/11/2022. // import SwiftUI extension View { public func sheetPlus( isPresented: Binding, animationCurve: SheetAnimation = SheetAnimation( mass: SheetAnimationDefaults.mass, stiffness: SheetAnimationDefaults.stiffness, damping: SheetAnimationDefaults.damping ), background: Background = Color(UIColor.systemBackground), onDismiss: @escaping () -> Void = {}, onDrag: @escaping (CGFloat) -> Void = { _ in }, header: () -> HContent = { EmptyView() }, main: () -> MContent ) -> some View { modifier( SheetPlus( isPresented: isPresented, animationCurve: animationCurve, background: background, onDismiss: onDismiss, onDrag: onDrag, hcontent: header, mcontent: main ) ) } } ================================================ FILE: Sources/BottomSheet/Views/DragIndicator.swift ================================================ // // DragIndicator.swift // // // Created by Wouter van de Kamp on 29/10/2023. // import SwiftUI struct DragIndicator: View { @Binding var translation: CGFloat var detents: Set var body: some View { RoundedRectangle(cornerRadius: 3) .contentShape(Rectangle()) .frame(width: 42, height: 6) .foregroundColor(Color(UIColor.systemGray3)) .padding(.vertical, 8) .onTapGesture { let sortedDetents = detents.sorted { $0.size < $1.size } let nextDetent = sortedDetents.first(where: { $0.size > translation }) if let nextDetent = nextDetent { translation = nextDetent.size } else { translation = sortedDetents.first!.size } } } } struct DragIndicator_Previews: PreviewProvider { static var previews: some View { DragIndicator( translation: .constant(0), detents: [] ) } } ================================================ FILE: Tests/BottomSheetTests/BottomSheetTests.swift ================================================ import XCTest @testable import BottomSheet final class BottomSheetTests: XCTestCase { func testExample() throws { } }