├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── docc.yml ├── .gitignore ├── .swiftlint.yml ├── Demo ├── Demo.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── Demo │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── Icon-iOS-1024.png │ ├── Contents.json │ └── header.imageset │ │ ├── Contents.json │ │ └── image.jpg │ ├── ContentView.swift │ ├── DemoApp.swift │ ├── DemoScreen.swift │ ├── Info.plist │ └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── LICENSE ├── Package.swift ├── README.md ├── RELEASE_NOTES.md ├── Resources ├── Demo.gif └── Icon.png ├── Sources └── ScrollKit │ ├── Examples │ ├── Examples+Spotify.swift │ ├── Examples+SpotifyAlbum.swift │ ├── Examples+SpotifyAlbumScreen.swift │ ├── Examples+SpotifyAlbumScreenContent.swift │ ├── Examples+SpotifyAlbumScreenHeader.swift │ └── Examples.swift │ ├── Extensions │ └── View+RoundedScollContent.swift │ ├── Helpers │ ├── ScrollManager.swift │ └── ScrollViewOffsetTracker.swift │ ├── ScrollKit.docc │ ├── Articles │ │ └── Getting-Started-Article.md │ ├── Resources │ │ ├── Logo.png │ │ ├── Page.png │ │ └── Rounded-Corners.jpg │ └── ScrollKit.md │ ├── ScrollViewHeader.swift │ ├── ScrollViewHeaderGradient.swift │ ├── ScrollViewHeaderImage.swift │ ├── ScrollViewWithOffsetTracking.swift │ ├── ScrollViewWithStickyHeader.swift │ ├── StatusBar │ ├── StatusBarVisibileState.swift │ └── StatusBarVisibilityUpdater.swift │ └── _Deprecated │ ├── Spotify.swift │ └── View+Deprecated.swift ├── Tests └── ScrollKitTests │ └── ScrollKitTests.swift ├── package_version.sh └── scripts ├── build.sh ├── chmod.sh ├── docc.sh ├── framework.sh ├── git_default_branch.sh ├── package_docc.sh ├── package_framework.sh ├── package_name.sh ├── package_version.sh ├── sync_from.sh ├── test.sh ├── version.sh ├── version_bump.sh ├── version_number.sh ├── version_validate_git.sh └── version_validate_target.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [danielsaidi] 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow builds and tests the project. 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Build Runner 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-15 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: maxim-lobanov/setup-xcode@v1 18 | with: 19 | xcode-version: latest-stable 20 | - name: Build all platforms 21 | run: bash scripts/build.sh ${{ github.event.repository.name }} 22 | - name: Test iOS 23 | run: bash scripts/test.sh ${{ github.event.repository.name }} 24 | -------------------------------------------------------------------------------- /.github/workflows/docc.yml: -------------------------------------------------------------------------------- 1 | # This workflow builds publish DocC docs to GitHub Pages. 2 | # Source: https://maxxfrazer.medium.com/deploying-docc-with-github-actions-218c5ca6cad5 3 | # Sample: https://github.com/AgoraIO-Community/VideoUIKit-iOS/blob/main/.github/workflows/deploy_docs.yml 4 | 5 | name: DocC Runner 6 | 7 | on: 8 | push: 9 | branches: ["main"] 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow one concurrent deployment 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | deploy: 24 | environment: 25 | name: github-pages 26 | url: ${{ steps.deployment.outputs.page_url }} 27 | runs-on: macos-15 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v3 31 | - id: pages 32 | name: Setup Pages 33 | uses: actions/configure-pages@v4 34 | - name: Select Xcode version 35 | uses: maxim-lobanov/setup-xcode@v1 36 | with: 37 | xcode-version: latest-stable 38 | - name: Build DocC 39 | run: bash scripts/docc.sh ${{ github.event.repository.name }} 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: '.build/docs-iOS' 44 | - id: deployment 45 | name: Deploy to GitHub Pages 46 | uses: actions/deploy-pages@v4 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | .swiftpm/ 5 | xcuserdata/ 6 | DerivedData/ -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_whitespace 3 | - line_length 4 | - vertical_whitespace 5 | 6 | identifier_name: 7 | excluded: 8 | - id -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 60; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A97CA013298EBEC6001EB4F3 /* DemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97CA012298EBEC6001EB4F3 /* DemoApp.swift */; }; 11 | A97CA015298EBEC6001EB4F3 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97CA014298EBEC6001EB4F3 /* ContentView.swift */; }; 12 | A97CA017298EBEC7001EB4F3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A97CA016298EBEC7001EB4F3 /* Assets.xcassets */; }; 13 | A97CA01A298EBEC7001EB4F3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A97CA019298EBEC7001EB4F3 /* Preview Assets.xcassets */; }; 14 | A97CA023298EBF8F001EB4F3 /* DemoScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97CA022298EBF8F001EB4F3 /* DemoScreen.swift */; }; 15 | A97CA026298EC05D001EB4F3 /* ScrollKit in Frameworks */ = {isa = PBXBuildFile; productRef = A97CA025298EC05D001EB4F3 /* ScrollKit */; }; 16 | A9CBE3032CB13D8100DFE99A /* ScrollKit in Frameworks */ = {isa = PBXBuildFile; productRef = A9CBE3022CB13D8100DFE99A /* ScrollKit */; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXFileReference section */ 20 | A97CA00F298EBEC6001EB4F3 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | A97CA012298EBEC6001EB4F3 /* DemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoApp.swift; sourceTree = ""; }; 22 | A97CA014298EBEC6001EB4F3 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 23 | A97CA016298EBEC7001EB4F3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 24 | A97CA019298EBEC7001EB4F3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 25 | A97CA022298EBF8F001EB4F3 /* DemoScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoScreen.swift; sourceTree = ""; }; 26 | A97CA027298ECAAD001EB4F3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 27 | /* End PBXFileReference section */ 28 | 29 | /* Begin PBXFrameworksBuildPhase section */ 30 | A97CA00C298EBEC6001EB4F3 /* Frameworks */ = { 31 | isa = PBXFrameworksBuildPhase; 32 | buildActionMask = 2147483647; 33 | files = ( 34 | A9CBE3032CB13D8100DFE99A /* ScrollKit in Frameworks */, 35 | A97CA026298EC05D001EB4F3 /* ScrollKit in Frameworks */, 36 | ); 37 | runOnlyForDeploymentPostprocessing = 0; 38 | }; 39 | /* End PBXFrameworksBuildPhase section */ 40 | 41 | /* Begin PBXGroup section */ 42 | A97CA006298EBEC6001EB4F3 = { 43 | isa = PBXGroup; 44 | children = ( 45 | A97CA011298EBEC6001EB4F3 /* Demo */, 46 | A97CA010298EBEC6001EB4F3 /* Products */, 47 | A97CA024298EC05D001EB4F3 /* Frameworks */, 48 | ); 49 | sourceTree = ""; 50 | }; 51 | A97CA010298EBEC6001EB4F3 /* Products */ = { 52 | isa = PBXGroup; 53 | children = ( 54 | A97CA00F298EBEC6001EB4F3 /* Demo.app */, 55 | ); 56 | name = Products; 57 | sourceTree = ""; 58 | }; 59 | A97CA011298EBEC6001EB4F3 /* Demo */ = { 60 | isa = PBXGroup; 61 | children = ( 62 | A97CA018298EBEC7001EB4F3 /* Preview Content */, 63 | A97CA027298ECAAD001EB4F3 /* Info.plist */, 64 | A97CA012298EBEC6001EB4F3 /* DemoApp.swift */, 65 | A97CA022298EBF8F001EB4F3 /* DemoScreen.swift */, 66 | A97CA014298EBEC6001EB4F3 /* ContentView.swift */, 67 | A97CA016298EBEC7001EB4F3 /* Assets.xcassets */, 68 | ); 69 | path = Demo; 70 | sourceTree = ""; 71 | }; 72 | A97CA018298EBEC7001EB4F3 /* Preview Content */ = { 73 | isa = PBXGroup; 74 | children = ( 75 | A97CA019298EBEC7001EB4F3 /* Preview Assets.xcassets */, 76 | ); 77 | path = "Preview Content"; 78 | sourceTree = ""; 79 | }; 80 | A97CA024298EC05D001EB4F3 /* Frameworks */ = { 81 | isa = PBXGroup; 82 | children = ( 83 | ); 84 | name = Frameworks; 85 | sourceTree = ""; 86 | }; 87 | /* End PBXGroup section */ 88 | 89 | /* Begin PBXNativeTarget section */ 90 | A97CA00E298EBEC6001EB4F3 /* Demo */ = { 91 | isa = PBXNativeTarget; 92 | buildConfigurationList = A97CA01D298EBEC7001EB4F3 /* Build configuration list for PBXNativeTarget "Demo" */; 93 | buildPhases = ( 94 | A97CA00B298EBEC6001EB4F3 /* Sources */, 95 | A97CA00C298EBEC6001EB4F3 /* Frameworks */, 96 | A97CA00D298EBEC6001EB4F3 /* Resources */, 97 | ); 98 | buildRules = ( 99 | ); 100 | dependencies = ( 101 | ); 102 | name = Demo; 103 | packageProductDependencies = ( 104 | A97CA025298EC05D001EB4F3 /* ScrollKit */, 105 | A9CBE3022CB13D8100DFE99A /* ScrollKit */, 106 | ); 107 | productName = Demo; 108 | productReference = A97CA00F298EBEC6001EB4F3 /* Demo.app */; 109 | productType = "com.apple.product-type.application"; 110 | }; 111 | /* End PBXNativeTarget section */ 112 | 113 | /* Begin PBXProject section */ 114 | A97CA007298EBEC6001EB4F3 /* Project object */ = { 115 | isa = PBXProject; 116 | attributes = { 117 | BuildIndependentTargetsInParallel = 1; 118 | LastSwiftUpdateCheck = 1420; 119 | LastUpgradeCheck = 1420; 120 | ORGANIZATIONNAME = "Daniel Saidi"; 121 | TargetAttributes = { 122 | A97CA00E298EBEC6001EB4F3 = { 123 | CreatedOnToolsVersion = 14.2; 124 | }; 125 | }; 126 | }; 127 | buildConfigurationList = A97CA00A298EBEC6001EB4F3 /* Build configuration list for PBXProject "Demo" */; 128 | compatibilityVersion = "Xcode 14.0"; 129 | developmentRegion = en; 130 | hasScannedForEncodings = 0; 131 | knownRegions = ( 132 | en, 133 | Base, 134 | ); 135 | mainGroup = A97CA006298EBEC6001EB4F3; 136 | packageReferences = ( 137 | A9CBE3012CB13D8100DFE99A /* XCLocalSwiftPackageReference "../../scrollkit" */, 138 | ); 139 | productRefGroup = A97CA010298EBEC6001EB4F3 /* Products */; 140 | projectDirPath = ""; 141 | projectRoot = ""; 142 | targets = ( 143 | A97CA00E298EBEC6001EB4F3 /* Demo */, 144 | ); 145 | }; 146 | /* End PBXProject section */ 147 | 148 | /* Begin PBXResourcesBuildPhase section */ 149 | A97CA00D298EBEC6001EB4F3 /* Resources */ = { 150 | isa = PBXResourcesBuildPhase; 151 | buildActionMask = 2147483647; 152 | files = ( 153 | A97CA01A298EBEC7001EB4F3 /* Preview Assets.xcassets in Resources */, 154 | A97CA017298EBEC7001EB4F3 /* Assets.xcassets in Resources */, 155 | ); 156 | runOnlyForDeploymentPostprocessing = 0; 157 | }; 158 | /* End PBXResourcesBuildPhase section */ 159 | 160 | /* Begin PBXSourcesBuildPhase section */ 161 | A97CA00B298EBEC6001EB4F3 /* Sources */ = { 162 | isa = PBXSourcesBuildPhase; 163 | buildActionMask = 2147483647; 164 | files = ( 165 | A97CA015298EBEC6001EB4F3 /* ContentView.swift in Sources */, 166 | A97CA023298EBF8F001EB4F3 /* DemoScreen.swift in Sources */, 167 | A97CA013298EBEC6001EB4F3 /* DemoApp.swift in Sources */, 168 | ); 169 | runOnlyForDeploymentPostprocessing = 0; 170 | }; 171 | /* End PBXSourcesBuildPhase section */ 172 | 173 | /* Begin XCBuildConfiguration section */ 174 | A97CA01B298EBEC7001EB4F3 /* Debug */ = { 175 | isa = XCBuildConfiguration; 176 | buildSettings = { 177 | ALWAYS_SEARCH_USER_PATHS = NO; 178 | CLANG_ANALYZER_NONNULL = YES; 179 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 180 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 181 | CLANG_ENABLE_MODULES = YES; 182 | CLANG_ENABLE_OBJC_ARC = YES; 183 | CLANG_ENABLE_OBJC_WEAK = YES; 184 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 185 | CLANG_WARN_BOOL_CONVERSION = YES; 186 | CLANG_WARN_COMMA = YES; 187 | CLANG_WARN_CONSTANT_CONVERSION = YES; 188 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 189 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 190 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 191 | CLANG_WARN_EMPTY_BODY = YES; 192 | CLANG_WARN_ENUM_CONVERSION = YES; 193 | CLANG_WARN_INFINITE_RECURSION = YES; 194 | CLANG_WARN_INT_CONVERSION = YES; 195 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 196 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 197 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 198 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 199 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 200 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 201 | CLANG_WARN_STRICT_PROTOTYPES = YES; 202 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 203 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 204 | CLANG_WARN_UNREACHABLE_CODE = YES; 205 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 206 | COPY_PHASE_STRIP = NO; 207 | DEBUG_INFORMATION_FORMAT = dwarf; 208 | ENABLE_STRICT_OBJC_MSGSEND = YES; 209 | ENABLE_TESTABILITY = YES; 210 | GCC_C_LANGUAGE_STANDARD = gnu11; 211 | GCC_DYNAMIC_NO_PIC = NO; 212 | GCC_NO_COMMON_BLOCKS = YES; 213 | GCC_OPTIMIZATION_LEVEL = 0; 214 | GCC_PREPROCESSOR_DEFINITIONS = ( 215 | "DEBUG=1", 216 | "$(inherited)", 217 | ); 218 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 219 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 220 | GCC_WARN_UNDECLARED_SELECTOR = YES; 221 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 222 | GCC_WARN_UNUSED_FUNCTION = YES; 223 | GCC_WARN_UNUSED_VARIABLE = YES; 224 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 225 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 226 | MTL_FAST_MATH = YES; 227 | ONLY_ACTIVE_ARCH = YES; 228 | SDKROOT = iphoneos; 229 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 230 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 231 | }; 232 | name = Debug; 233 | }; 234 | A97CA01C298EBEC7001EB4F3 /* Release */ = { 235 | isa = XCBuildConfiguration; 236 | buildSettings = { 237 | ALWAYS_SEARCH_USER_PATHS = NO; 238 | CLANG_ANALYZER_NONNULL = YES; 239 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 240 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 241 | CLANG_ENABLE_MODULES = YES; 242 | CLANG_ENABLE_OBJC_ARC = YES; 243 | CLANG_ENABLE_OBJC_WEAK = YES; 244 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 245 | CLANG_WARN_BOOL_CONVERSION = YES; 246 | CLANG_WARN_COMMA = YES; 247 | CLANG_WARN_CONSTANT_CONVERSION = YES; 248 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 249 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 250 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 251 | CLANG_WARN_EMPTY_BODY = YES; 252 | CLANG_WARN_ENUM_CONVERSION = YES; 253 | CLANG_WARN_INFINITE_RECURSION = YES; 254 | CLANG_WARN_INT_CONVERSION = YES; 255 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 256 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 257 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 258 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 259 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 260 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 261 | CLANG_WARN_STRICT_PROTOTYPES = YES; 262 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 263 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 264 | CLANG_WARN_UNREACHABLE_CODE = YES; 265 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 266 | COPY_PHASE_STRIP = NO; 267 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 268 | ENABLE_NS_ASSERTIONS = NO; 269 | ENABLE_STRICT_OBJC_MSGSEND = YES; 270 | GCC_C_LANGUAGE_STANDARD = gnu11; 271 | GCC_NO_COMMON_BLOCKS = YES; 272 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 273 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 274 | GCC_WARN_UNDECLARED_SELECTOR = YES; 275 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 276 | GCC_WARN_UNUSED_FUNCTION = YES; 277 | GCC_WARN_UNUSED_VARIABLE = YES; 278 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 279 | MTL_ENABLE_DEBUG_INFO = NO; 280 | MTL_FAST_MATH = YES; 281 | SDKROOT = iphoneos; 282 | SWIFT_COMPILATION_MODE = wholemodule; 283 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 284 | VALIDATE_PRODUCT = YES; 285 | }; 286 | name = Release; 287 | }; 288 | A97CA01E298EBEC7001EB4F3 /* Debug */ = { 289 | isa = XCBuildConfiguration; 290 | buildSettings = { 291 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 292 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 293 | CODE_SIGN_STYLE = Automatic; 294 | CURRENT_PROJECT_VERSION = 1; 295 | DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; 296 | DEVELOPMENT_TEAM = PMEDFW438U; 297 | ENABLE_PREVIEWS = YES; 298 | GENERATE_INFOPLIST_FILE = YES; 299 | INFOPLIST_FILE = Demo/Info.plist; 300 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 301 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 302 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 303 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 304 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 305 | LD_RUNPATH_SEARCH_PATHS = ( 306 | "$(inherited)", 307 | "@executable_path/Frameworks", 308 | ); 309 | MARKETING_VERSION = 1.0; 310 | PRODUCT_BUNDLE_IDENTIFIER = com.danielsaidi.scrollkit.Demo; 311 | PRODUCT_NAME = "$(TARGET_NAME)"; 312 | SWIFT_EMIT_LOC_STRINGS = YES; 313 | SWIFT_VERSION = 5.0; 314 | TARGETED_DEVICE_FAMILY = "1,2"; 315 | }; 316 | name = Debug; 317 | }; 318 | A97CA01F298EBEC7001EB4F3 /* Release */ = { 319 | isa = XCBuildConfiguration; 320 | buildSettings = { 321 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 322 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 323 | CODE_SIGN_STYLE = Automatic; 324 | CURRENT_PROJECT_VERSION = 1; 325 | DEVELOPMENT_ASSET_PATHS = "\"Demo/Preview Content\""; 326 | DEVELOPMENT_TEAM = PMEDFW438U; 327 | ENABLE_PREVIEWS = YES; 328 | GENERATE_INFOPLIST_FILE = YES; 329 | INFOPLIST_FILE = Demo/Info.plist; 330 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 331 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 332 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 333 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 334 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 335 | LD_RUNPATH_SEARCH_PATHS = ( 336 | "$(inherited)", 337 | "@executable_path/Frameworks", 338 | ); 339 | MARKETING_VERSION = 1.0; 340 | PRODUCT_BUNDLE_IDENTIFIER = com.danielsaidi.scrollkit.Demo; 341 | PRODUCT_NAME = "$(TARGET_NAME)"; 342 | SWIFT_EMIT_LOC_STRINGS = YES; 343 | SWIFT_VERSION = 5.0; 344 | TARGETED_DEVICE_FAMILY = "1,2"; 345 | }; 346 | name = Release; 347 | }; 348 | /* End XCBuildConfiguration section */ 349 | 350 | /* Begin XCConfigurationList section */ 351 | A97CA00A298EBEC6001EB4F3 /* Build configuration list for PBXProject "Demo" */ = { 352 | isa = XCConfigurationList; 353 | buildConfigurations = ( 354 | A97CA01B298EBEC7001EB4F3 /* Debug */, 355 | A97CA01C298EBEC7001EB4F3 /* Release */, 356 | ); 357 | defaultConfigurationIsVisible = 0; 358 | defaultConfigurationName = Release; 359 | }; 360 | A97CA01D298EBEC7001EB4F3 /* Build configuration list for PBXNativeTarget "Demo" */ = { 361 | isa = XCConfigurationList; 362 | buildConfigurations = ( 363 | A97CA01E298EBEC7001EB4F3 /* Debug */, 364 | A97CA01F298EBEC7001EB4F3 /* Release */, 365 | ); 366 | defaultConfigurationIsVisible = 0; 367 | defaultConfigurationName = Release; 368 | }; 369 | /* End XCConfigurationList section */ 370 | 371 | /* Begin XCLocalSwiftPackageReference section */ 372 | A9CBE3012CB13D8100DFE99A /* XCLocalSwiftPackageReference "../../scrollkit" */ = { 373 | isa = XCLocalSwiftPackageReference; 374 | relativePath = ../../scrollkit; 375 | }; 376 | /* End XCLocalSwiftPackageReference section */ 377 | 378 | /* Begin XCSwiftPackageProductDependency section */ 379 | A97CA025298EC05D001EB4F3 /* ScrollKit */ = { 380 | isa = XCSwiftPackageProductDependency; 381 | productName = ScrollKit; 382 | }; 383 | A9CBE3022CB13D8100DFE99A /* ScrollKit */ = { 384 | isa = XCSwiftPackageProductDependency; 385 | productName = ScrollKit; 386 | }; 387 | /* End XCSwiftPackageProductDependency section */ 388 | }; 389 | rootObject = A97CA007298EBEC6001EB4F3 /* Project object */; 390 | } 391 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-iOS-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-iOS-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ScrollKit/874399d2431ef57d365e985f3088c9f5f243efb1/Demo/Demo/Assets.xcassets/AppIcon.appiconset/Icon-iOS-1024.png -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/header.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "image.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/header.imageset/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ScrollKit/874399d2431ef57d365e985f3088c9f5f243efb1/Demo/Demo/Assets.xcassets/header.imageset/image.jpg -------------------------------------------------------------------------------- /Demo/Demo/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-04. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ScrollKit 11 | 12 | struct ContentView: View { 13 | 14 | var body: some View { 15 | NavigationView { 16 | List { 17 | linkSection 18 | spotifySection 19 | } 20 | .tint(.blue) 21 | .navigationTitle("Demo") 22 | .toolbarColorScheme(.light, for: .navigationBar) 23 | } 24 | .tint(.white) 25 | .navigationViewStyle(.stack) 26 | .toolbarRole(.navigationStack) 27 | } 28 | } 29 | 30 | private extension ContentView { 31 | 32 | var linkSection: some View { 33 | Section(header: Text("Sticky headers")) { 34 | imageLink 35 | gradientLink 36 | colorLink 37 | } 38 | } 39 | 40 | var spotifySection: some View { 41 | Section(header: Text("Spotify screens")) { 42 | spotifyLink(.anthrax) 43 | spotifyLink(.misfortune) 44 | spotifyLink(.regina) 45 | } 46 | } 47 | } 48 | 49 | private extension ContentView { 50 | 51 | var colorLink: some View { 52 | link("paintbrush.pointed.fill", "Color") { 53 | DemoScreen(headerHeight: 100) { 54 | Color.blue 55 | } 56 | } 57 | } 58 | 59 | var gradientLink: some View { 60 | link("paintbrush.fill", "Gradient") { 61 | DemoScreen(headerHeight: 250) { 62 | ScrollViewHeaderGradient(.yellow, .blue) 63 | } 64 | } 65 | } 66 | 67 | var imageLink: some View { 68 | link("photo.fill", "Image") { 69 | DemoScreen(headerHeight: 250) { 70 | ZStack { 71 | ScrollViewHeaderImage(Image("header")) 72 | ScrollViewHeaderGradient(.black.opacity(0.2), .black.opacity(0.5)) 73 | } 74 | } 75 | } 76 | } 77 | 78 | func spotifyLink(_ info: Examples.Spotify.Album) -> some View { 79 | link("record.circle.fill", "Spotify - \(info.bandName)") { 80 | Examples.Spotify.AlbumScreen(album: info) 81 | } 82 | } 83 | 84 | func link(_ icon: String, _ title: String, to destination: () -> Destination) -> some View { 85 | NavigationLink(destination: destination) { 86 | Label(title, systemImage: icon) 87 | } 88 | } 89 | } 90 | 91 | #Preview { 92 | ContentView() 93 | } 94 | -------------------------------------------------------------------------------- /Demo/Demo/DemoApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoApp.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-04. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct DemoApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Demo/Demo/DemoScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoScreen.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-04. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import ScrollKit 10 | import SwiftUI 11 | 12 | /// This view takes a custom header view and height and adds 13 | /// it to a scroll view with sticky header. 14 | struct DemoScreen: View { 15 | 16 | let headerHeight: CGFloat 17 | 18 | @ViewBuilder 19 | let headerView: () -> HeaderView 20 | 21 | @State 22 | private var visibleHeaderRatio: CGFloat = 1 23 | 24 | @State 25 | private var scrollOffset: CGPoint = .zero 26 | 27 | private let scrollManager = ScrollManager() 28 | 29 | var body: some View { 30 | ScrollViewWithStickyHeader( 31 | header: header, 32 | headerHeight: headerHeight, 33 | headerMinHeight: 75, 34 | scrollManager: scrollManager, 35 | onScroll: handleScrollOffset 36 | ) { 37 | LazyVStack(spacing: 0) { 38 | ForEach(1...100, id: \.self) { item in 39 | VStack(spacing: 0) { 40 | Text("Item \(item)") 41 | .padding() 42 | .frame(maxWidth: .infinity, alignment: .leading) 43 | Divider() 44 | } 45 | } 46 | } 47 | } 48 | .toolbar { 49 | ToolbarItem(placement: .principal) { 50 | Text("Demo Title") 51 | .font(.headline) 52 | .previewHeaderContent() 53 | .opacity(1 - visibleHeaderRatio) 54 | } 55 | ToolbarItemGroup(placement: .topBarTrailing) { 56 | Menu("Scroll to...") { 57 | Button("Header") { 58 | scrollManager.scroll(to: .header) 59 | } 60 | Button("Content") { 61 | scrollManager.scroll(to: .content) 62 | } 63 | } 64 | } 65 | } 66 | .toolbarBackground(.hidden) 67 | .statusBarHidden(scrollOffset.y > -3) 68 | .toolbarColorScheme(.dark, for: .navigationBar) 69 | } 70 | 71 | func header() -> some View { 72 | ZStack(alignment: .bottomLeading) { 73 | headerView() 74 | ScrollViewHeaderGradient() 75 | headerTitle.previewHeaderContent() 76 | } 77 | } 78 | 79 | var headerTitle: some View { 80 | VStack(alignment: .leading, spacing: 5) { 81 | Text("Demo Title").font(.largeTitle) 82 | Text("Some additional information") 83 | } 84 | .padding(20) 85 | .opacity(visibleHeaderRatio) 86 | } 87 | 88 | func handleScrollOffset(_ offset: CGPoint, visibleHeaderRatio: CGFloat) { 89 | self.scrollOffset = offset 90 | self.visibleHeaderRatio = visibleHeaderRatio 91 | } 92 | } 93 | 94 | #Preview { 95 | 96 | NavigationView { 97 | DemoScreen( 98 | headerHeight: 250, 99 | headerView: { 100 | ScrollViewHeaderImage(Image("header")) 101 | } 102 | ) 103 | } 104 | } 105 | 106 | private extension View { 107 | 108 | func previewHeaderContent() -> some View { 109 | self.foregroundColor(.white) 110 | .shadow(color: .black.opacity(0.4), radius: 1, x: 1, y: 1) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Demo/Demo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIViewControllerBasedStatusBarAppearance 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Demo/Demo/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 Daniel Saidi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ScrollKit", 7 | platforms: [ 8 | .iOS(.v15), 9 | .macOS(.v12), 10 | .tvOS(.v15), 11 | .watchOS(.v8), 12 | .visionOS(.v1) 13 | ], 14 | products: [ 15 | .library( 16 | name: "ScrollKit", 17 | targets: ["ScrollKit"] 18 | ) 19 | ], 20 | dependencies: [], 21 | targets: [ 22 | .target( 23 | name: "ScrollKit" 24 | ), 25 | .testTarget( 26 | name: "ScrollKitTests", 27 | dependencies: ["ScrollKit"] 28 | ) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Project Icon 3 |

4 | 5 |

6 | Version 7 | Swift 6.0 8 | Swift UI 9 | Documentation 10 | MIT License 11 | Sponsor my work 12 |

13 | 14 | 15 | 16 | # ScrollKit 17 | 18 | ScrollKit is a SwiftUI library that adds powerful scroll features, like offset tracking and a header view that stretches & transforms as you pull down, and sticks to the top when you scroll. 19 | 20 |

21 | 22 |

23 | 24 | ScrollKit works on all major Apple platforms and is designed to be easy to use. It doesn't use the new `ScrollView` APIs for backwards compatibility reasons, but will eventually do so. 25 | 26 | 27 | 28 | ## Installation 29 | 30 | ScrollKit can be installed with the Swift Package Manager: 31 | 32 | ``` 33 | https://github.com/danielsaidi/ScrollKit.git 34 | ``` 35 | 36 | 37 | ## Support My Work 38 | 39 | You can [become a sponsor][Sponsors] to help me dedicate more time on my various [open-source tools][OpenSource]. Every contribution, no matter the size, makes a real difference in keeping these tools free and actively developed. 40 | 41 | 42 | 43 | ## Getting started 44 | 45 | ScrollKit has a `ScrollViewWithOffsetTracking` that can detect scrolling on all OS versions: 46 | 47 | ```swift 48 | ScrollViewWithOffsetTracking { offset in 49 | print(offset) 50 | } content: { 51 | // Add your scroll content here, e.g. a `LazyVStack` 52 | } 53 | ``` 54 | 55 | ScrollKit has a `ScrollViewWithStickyHeader` that makes it easy to set up a stretchy, sticky header: 56 | 57 | ```swift 58 | import SwiftUI 59 | import ScrollKit 60 | 61 | struct MyView: View { 62 | 63 | @State 64 | private var scrollOffset = CGPoint.zero 65 | 66 | @State 67 | private var visibleRatio = CGFloat.zero 68 | 69 | var body: some View { 70 | ScrollViewWithStickyHeader( 71 | header: stickyHeader, // A header view 72 | headerHeight: 250, // The resting header height 73 | headerMinHeight: 150, // The minimum header height 74 | headerStretch: false, // Disables the stretch effect 75 | contentCornerRadius: 20, // An optional corner radius mask 76 | onScroll: handleScroll // An optional scroll handler action 77 | ) { 78 | // Add your scroll content here, e.g. a `LazyVStack` 79 | } 80 | } 81 | 82 | func handleScroll(_ offset: CGPoint, visibleHeaderRatio: CGFloat) { 83 | self.scrollOffset = offset 84 | self.visibleRatio = visibleHeaderRatio 85 | } 86 | 87 | func stickyHeader() -> some View { 88 | ZStack { 89 | Color.red 90 | ScrollViewHeaderGradient() // By default a dark gradient 91 | Text("Scroll offset: \(scrollOffset.y)") 92 | } 93 | } 94 | } 95 | ``` 96 | 97 | For more information, please see the [getting started guide][Getting-Started]. 98 | 99 | 100 | 101 | ## Documentation 102 | 103 | The [online documentation][Documentation] has more information, articles, code examples, etc. 104 | 105 | 106 | 107 | ## Demo Application 108 | 109 | The demo app lets you explore the library. To try it out, just open and run the `Demo` project. 110 | 111 | 112 | 113 | ## Contact 114 | 115 | Feel free to reach out if you have questions or want to contribute in any way: 116 | 117 | * Website: [danielsaidi.com][Website] 118 | * E-mail: [daniel.saidi@gmail.com][Email] 119 | * Bluesky: [@danielsaidi@bsky.social][Bluesky] 120 | * Mastodon: [@danielsaidi@mastodon.social][Mastodon] 121 | 122 | 123 | 124 | ## License 125 | 126 | ScrollKit is available under the MIT license. See the [LICENSE][License] file for more info. 127 | 128 | 129 | 130 | [Email]: mailto:daniel.saidi@gmail.com 131 | [Website]: https://danielsaidi.com 132 | [GitHub]: https://github.com/danielsaidi 133 | [OpenSource]: https://danielsaidi.com/opensource 134 | [Sponsors]: https://github.com/sponsors/danielsaidi 135 | 136 | [Bluesky]: https://bsky.app/profile/danielsaidi.bsky.social 137 | [Mastodon]: https://mastodon.social/@danielsaidi 138 | [Twitter]: https://twitter.com/danielsaidi 139 | 140 | [Documentation]: https://danielsaidi.github.io/ScrollKit/ 141 | [Getting-Started]: https://danielsaidi.github.io/ScrollKit/documentation/scrollkit/getting-started-article 142 | [License]: https://github.com/danielsaidi/ScrollKit/blob/master/LICENSE 143 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ScrollKit will use semver after 1.0. 4 | 5 | Until then, deprecated features may be removed in any minor version. 6 | 7 | 8 | 9 | ## 0.8 10 | 11 | Thanks to [@gabrielribeiro](https://github.com/gabrielribeiro), this version adds a `ScrollManager` that can be used to scroll to certain parts of a scroll view. 12 | 13 | This version also fixes a few 0.7 linting errors. 14 | 15 | ### ✨ Features 16 | 17 | * The `ScrollManager` is a new type that can be used to scroll within a scroll view. 18 | * The `ScrollViewWithStickyHeader` can now take a `ScrollManager` in its initializer. 19 | 20 | ### 🐛 Bug Fixes 21 | 22 | * This version fixes linting errors that were accidentally introduced in 0.7, since Xcode 16.3 isn't able to run build scripts. 23 | 24 | 25 | 26 | ## 0.7.1 27 | 28 | This version fixes a few 0.7 bugs and behaviors. 29 | 30 | ### 💡 Behavior Changes 31 | 32 | * The `ScrollViewWithStickyHeader` now defaults the minimum header height to the top safe area, instead of the header height. 33 | * The `scrollViewHeaderWithRoundedContentMask(_:)` view extension has been renamed to `scrollViewHeaderWithRoundedContentCorners(cornerRadius:)` for clarity. 34 | 35 | ### 🐛 Bug Fixes 36 | 37 | * The `ScrollViewWithStickyHeader` no longer prints as it scrolls. 38 | * The `scrollViewHeaderWithRoundedContentMask(_:)` view extension didn't apply the provided corner radius. This has been fixed. 39 | 40 | 41 | 42 | ## 0.7 43 | 44 | This version moves examples into a new namespace and renames some View extensions to make more sense. 45 | 46 | ### ✨ New Features 47 | 48 | * The `ScrollViewWithStickyHeader` has a new `headerStretch` parameter. 49 | * The `ScrollViewWithStickyHeader` has a new `contentCornerRadius` parameter. 50 | * The new `scrollViewContentWithHeaderOverlap(...)` view extension can apply a header overlap to a scroll view content view. 51 | * The new `scrollViewContentWithRoundedHeaderOverlap(...)` view extension can apply a rounded header overlap to a scroll view content view. 52 | * The new `scrollViewHeaderWithRoundedContentMask(...)` view extension can apply a rounded corner mask to a scroll view header. 53 | 54 | ### 💡 Behavior Changes 55 | 56 | * `ScrollViewWithStickyHeader` now wraps its content in a `GeometryReader` to properly handle scroll offset. 57 | 58 | ### 🐛 Bug Fixes 59 | 60 | * `ScrollViewWithStickyHeader` now honors the provided min height better, by using the geometry reader safe area insets. 61 | 62 | ### 🗑️ Deprecations 63 | 64 | * All examples have been moved into a new `Examples` namespace. 65 | * The `hideStatusBarUntilScrolled` view extension has been renamed to `statusBarHiddenUntilScrolled`. 66 | * The `withScrollOffsetTracking` view extension has been renamed to `scrollViewOffsetTracking`. 67 | 68 | 69 | 70 | ## 0.6.1 71 | 72 | This version adjusts the code to address a concurrency warning. 73 | 74 | 75 | 76 | ## 0.6 77 | 78 | This version makes ScrollKit use Swift 6. 79 | 80 | 81 | 82 | ## 0.5.1 83 | 84 | This version adds support for strict concurrency. 85 | 86 | Thanks to murilocappucci@gmail.com, the `ScrollViewWithStickyHeader` now properly passes on the axis and indicator parameters to `ScrollViewWithOffsetTracking`. 87 | 88 | 89 | 90 | ## 0.5 91 | 92 | This version makes more stuff public and adds support form visionOS. 93 | 94 | 95 | 96 | ## 0.4 97 | 98 | This version updates to Swift 5.9, bumps deployment targets and removes CocoaPods support. 99 | 100 | This version also moves all Spotify-related previews into a `Spotify` namespace. 101 | 102 | ### 🐛 Bug Fixes 103 | 104 | * `ScrollViewWithStickyHeader` now honors the provided min height. 105 | 106 | ### 💥 Breaking changes 107 | 108 | * `ScrollViewWithOffset` has been renamed to `ScrollViewWithOffsetTracking`. 109 | * `StatusBarVisibleState` has been renamed to the correct name. 110 | 111 | 112 | 113 | ## 0.3 114 | 115 | This update adds utilities for handling status bar visibility. 116 | 117 | ### ✨ New Features 118 | 119 | * `StatusBarVisibleState` is a new class for handling status bar visibility with shared state, for instance when using `NavigationStack`. 120 | * `StatusBarVisibilityUpdater` is a new view modifier to automatically update the status bar visibility depending on the current scroll state. 121 | 122 | 123 | 124 | ## 0.2 125 | 126 | This minor update adds some public previews that you can use in your own apps. 127 | 128 | ### ✨ New Features 129 | 130 | * `SpotifyPreviewInfo` is a new struct that defines preview info for the Spotify previews. 131 | * `SpotifyPreviewInfo` has several pre-configured albums that you can use in the previews. 132 | * `SpotifyPreviewScreen`, `SpotifyPreviewHeader` and `SpotifyPreviewContent` are new preview views. 133 | 134 | 135 | 136 | ## 0.1 137 | 138 | This is the first public release of ScrollKit. 139 | 140 | ### ✨ New Features 141 | 142 | * `ScrollViewWithOffset` is a scroll view that provides you with the scroll offset. 143 | * `ScrollViewWithStickyHeader` is a scroll view that lets you provide a sticky header view. 144 | * `ScrollViewHeader` is a scroll view header that will automatically stretch out as the scroll view is pulled down. 145 | * `ScrollViewHeaderGradient` is a convenience view to quickly define a header gradient, e.g. a dark overlay. 146 | -------------------------------------------------------------------------------- /Resources/Demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ScrollKit/874399d2431ef57d365e985f3088c9f5f243efb1/Resources/Demo.gif -------------------------------------------------------------------------------- /Resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ScrollKit/874399d2431ef57d365e985f3088c9f5f243efb1/Resources/Icon.png -------------------------------------------------------------------------------- /Sources/ScrollKit/Examples/Examples+Spotify.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Examples+Spotify.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-12-04. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Examples { 12 | 13 | /// This namespace contains Spotify-related types. 14 | struct Spotify {} 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ScrollKit/Examples/Examples+SpotifyAlbum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Examples+SpotifyAlbum.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-03-10. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Examples.Spotify { 12 | 13 | /// This model is used by the Spotify previews. 14 | struct Album { 15 | 16 | public init( 17 | bandName: String, 18 | releaseTitle: String, 19 | releaseType: String, 20 | releaseDate: Date, 21 | releaseCoverUrl: String, 22 | tintColor: Color, 23 | tracks: [String] 24 | ) { 25 | self.bandName = bandName 26 | self.releaseTitle = releaseTitle 27 | self.releaseType = releaseType 28 | self.releaseDate = releaseDate 29 | self.releaseCoverUrl = releaseCoverUrl 30 | self.tintColor = tintColor 31 | self.tracks = tracks 32 | } 33 | 34 | public let bandName: String 35 | public let releaseTitle: String 36 | public let releaseType: String 37 | public let releaseDate: Date 38 | public let releaseCoverUrl: String 39 | public let tintColor: Color 40 | public let tracks: [String] 41 | } 42 | } 43 | 44 | public extension Examples.Spotify.Album { 45 | 46 | static var anthrax: Self { 47 | .init( 48 | bandName: "Anthrax", 49 | releaseTitle: "We've Come for You All", 50 | releaseType: "Album", 51 | releaseDate: Calendar.current.date(from: DateComponents(year: 2003)) ?? .now, 52 | releaseCoverUrl: "https://upload.wikimedia.org/wikipedia/en/8/8f/AnthraxWCFYA.jpg", 53 | tintColor: .init(red: 0.5, green: 0.4, blue: 0.5), 54 | tracks: [ 55 | "Contact", 56 | "What Doesn't Die", 57 | "Superhero", 58 | "Refuse to Be Denied", 59 | "Safe Home", 60 | "Any Place But Here", 61 | "Nobody Knows Anything", 62 | "Strap It On", 63 | "Black Dahlia", 64 | "Cadillac Rock Box", 65 | "Taking the Music Back", 66 | "Crash", 67 | "Think About an End", 68 | "We've Come for You All", 69 | "Safe Home - Acoustic Version", 70 | "We're Happy Family" 71 | ] 72 | ) 73 | } 74 | 75 | static var misfortune: Self { 76 | .init( 77 | bandName: "Misfortune", 78 | releaseTitle: "Forsaken", 79 | releaseType: "Album", 80 | releaseDate: Calendar.current.date(from: DateComponents(year: 1999)) ?? .now, 81 | releaseCoverUrl: "https://danielsaidi.com/assets/bands/misfortune/forsaken.jpg", 82 | tintColor: .init(red: 0.5, green: 0.3, blue: 0), 83 | tracks: [ 84 | "Forsaken", 85 | "A Scenery of Dispair", 86 | "Rape of Bewildered Dreams", 87 | "In Mating", 88 | "Burn!", 89 | "Through Chaos Fulfilled", 90 | "A Realm of the Unblessed", 91 | "Apostates of Hate" 92 | ] 93 | ) 94 | } 95 | 96 | static var regina: Self { 97 | .init( 98 | bandName: "Regina Spector", 99 | releaseTitle: "Far", 100 | releaseType: "Album", 101 | releaseDate: Calendar.current.date(from: DateComponents(year: 2009)) ?? .now, 102 | releaseCoverUrl: "https://i.scdn.co/image/ab67616d0000b2738c8d5428b693308705e7caca", 103 | tintColor: .init(red: 0.5, green: 0.7, blue: 1), 104 | tracks: [ 105 | "The Calculation", 106 | "Eet", 107 | "Blue Lips", 108 | "Folding Chair", 109 | "Machine", 110 | "Laughing With", 111 | "Human of the Year", 112 | "Two Birds", 113 | "Dance Anthem of the 80's", 114 | "Genius Next Door", 115 | "Wallet", 116 | "One More Time With Feeling", 117 | "Man of a Thousand Faces" 118 | ] 119 | ) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/ScrollKit/Examples/Examples+SpotifyAlbumScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Examples+SpotifyAlbumScreen.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-07. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Examples.Spotify { 12 | 13 | /// This view mimics a Spotify album screen. 14 | struct AlbumScreen: View { 15 | 16 | public init(album: Album) { 17 | self.album = album 18 | } 19 | 20 | private var album: Album 21 | 22 | @Environment(\.colorScheme) 23 | private var colorScheme 24 | 25 | @Environment(\.dismiss) 26 | private var dismiss 27 | 28 | @State 29 | private var visibleHeaderRatio = 1.0 30 | 31 | @State 32 | private var scrollOffset = CGPoint.zero 33 | 34 | private var scrollContentCornerRadius: Double { 35 | colorScheme == .dark ? 0.0 : 20 36 | } 37 | 38 | public var body: some View { 39 | ScrollViewWithStickyHeader( 40 | header: scrollViewHeader, 41 | headerHeight: Examples.Spotify.AlbumScreen.Header.height, 42 | headerMinHeight: 50, 43 | headerStretch: false, 44 | contentCornerRadius: scrollContentCornerRadius, 45 | onScroll: handleScrollOffset 46 | ) { 47 | if #available(iOS 16.0, *) { 48 | Examples.Spotify.AlbumScreen.Content(album: album) 49 | } 50 | } 51 | #if os(iOS) 52 | .hideBackButtonText() 53 | #endif 54 | #if os(iOS) || os(macOS) || os(tvOS) 55 | .toolbarTitle(toolbarTitleView) 56 | #endif 57 | } 58 | 59 | func scrollViewHeader() -> some View { 60 | Examples.Spotify.AlbumScreen.Header( 61 | album: album, 62 | bottomPadding: scrollContentCornerRadius, 63 | visibleHeaderRatio: visibleHeaderRatio 64 | ) 65 | } 66 | 67 | var toolbarTitleView: some View { 68 | Text(album.releaseTitle) 69 | .font(.headline.bold()) 70 | .opacity(visibleHeaderRatio > 0 ? 0 : -5 * visibleHeaderRatio) 71 | } 72 | 73 | func handleScrollOffset(_ offset: CGPoint, visibleHeaderRatio: CGFloat) { 74 | self.scrollOffset = offset 75 | self.visibleHeaderRatio = visibleHeaderRatio 76 | } 77 | } 78 | } 79 | 80 | private extension View { 81 | 82 | #if os(iOS) 83 | func hideBackButtonText() -> some View { 84 | self.toolbar { 85 | ToolbarItem(placement: .navigationBarLeading) { 86 | Text(" ") // Hides the back button text :D 87 | } 88 | } 89 | } 90 | #endif 91 | 92 | func toolbarTitle(_ view: Title) -> some View { 93 | #if os(iOS) || os(macOS) || os(tvOS) 94 | self.toolbar { 95 | ToolbarItem(placement: .principal) { 96 | view 97 | } 98 | } 99 | #else 100 | self 101 | #endif 102 | } 103 | } 104 | 105 | private struct Preview: View { 106 | 107 | var body: some View { 108 | Examples.Spotify.AlbumScreen(album: .misfortune) 109 | } 110 | } 111 | 112 | #Preview("Light") { 113 | 114 | Preview() 115 | } 116 | 117 | #Preview("Dark") { 118 | 119 | Preview().preferredColorScheme(.dark) 120 | } 121 | 122 | #Preview("Push") { 123 | 124 | NavigationView { 125 | #if os(macOS) 126 | Color.clear 127 | #endif 128 | NavigationLink("Test") { 129 | Preview() 130 | } 131 | } 132 | #if os(iOS) 133 | .navigationViewStyle(.stack) 134 | #endif 135 | } 136 | 137 | #if os(iOS) 138 | #Preview("Sheet") { 139 | 140 | struct SheetPreview: View { 141 | 142 | @State var isPresented = false 143 | 144 | var body: some View { 145 | Button("Present") { 146 | isPresented.toggle() 147 | } 148 | .sheet(isPresented: $isPresented) { 149 | NavigationView { 150 | Preview() 151 | } 152 | .navigationViewStyle(.stack) 153 | } 154 | } 155 | } 156 | 157 | return SheetPreview() 158 | } 159 | #endif 160 | -------------------------------------------------------------------------------- /Sources/ScrollKit/Examples/Examples+SpotifyAlbumScreenContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Examples+SpotifyAlbumScreenContent.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-06. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Examples.Spotify.AlbumScreen { 12 | 13 | /// This view mimics a Spotify album screen content view. 14 | struct Content: View { 15 | 16 | public init(album: Examples.Spotify.Album) { 17 | self.album = album 18 | } 19 | 20 | private let album: Examples.Spotify.Album 21 | 22 | public var body: some View { 23 | VStack(spacing: 20) { 24 | title 25 | buttons 26 | list 27 | } 28 | .padding() 29 | } 30 | } 31 | } 32 | 33 | private extension Examples.Spotify.AlbumScreen.Content { 34 | 35 | var title: some View { 36 | VStack(alignment: .leading, spacing: 8) { 37 | Text(album.releaseTitle) 38 | .font(.title2.bold()) 39 | .frame(maxWidth: .infinity, alignment: .leading) 40 | Text(album.bandName) 41 | .font(.footnote.bold()) 42 | Text("\(album.releaseType) · \(album.releaseDate.formatted(.dateTime.year()))") 43 | .font(.footnote.bold()) 44 | .foregroundColor(.secondary) 45 | } 46 | } 47 | 48 | var buttons: some View { 49 | HStack(spacing: 15) { 50 | Image(systemName: "heart") 51 | Image(systemName: "arrow.down.circle") 52 | Image(systemName: "ellipsis") 53 | Spacer() 54 | Image(systemName: "shuffle") 55 | Image(systemName: "play.circle.fill") 56 | .font(.largeTitle) 57 | .foregroundColor(.green) 58 | } 59 | .font(.title3) 60 | } 61 | 62 | var list: some View { 63 | LazyVStack(alignment: .leading, spacing: 30) { 64 | ForEach(Array(album.tracks.enumerated()), id: \.offset) { 65 | listItem($0.element) 66 | } 67 | } 68 | } 69 | 70 | func listItem(_ song: String) -> some View { 71 | VStack(alignment: .leading) { 72 | Text(song).font(.headline) 73 | Text(album.bandName) 74 | .font(.footnote) 75 | .foregroundColor(.secondary) 76 | } 77 | } 78 | } 79 | 80 | #Preview { 81 | 82 | ScrollView { 83 | Examples.Spotify.AlbumScreen.Content(album: .anthrax) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/ScrollKit/Examples/Examples+SpotifyAlbumScreenHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Examples+SpotifyAlbumScreenHeader.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-06. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Examples.Spotify.AlbumScreen { 12 | 13 | /// This view mimics a Spotify album screen header. 14 | struct Header: View { 15 | 16 | public init( 17 | album: Examples.Spotify.Album, 18 | bottomPadding: Double = 0, 19 | visibleHeaderRatio: CGFloat = 1 20 | ) { 21 | self.album = album 22 | self.bottomPadding = bottomPadding 23 | self.visibleHeaderRatio = visibleHeaderRatio 24 | } 25 | 26 | public static let height: CGFloat = 280 27 | 28 | private let album: Examples.Spotify.Album 29 | private let bottomPadding: Double 30 | private let visibleHeaderRatio: CGFloat 31 | 32 | public var body: some View { 33 | ZStack { 34 | ScrollViewHeaderGradient(album.tintColor, .black) 35 | ScrollViewHeaderGradient(album.tintColor.opacity(1), album.tintColor.opacity(0)) 36 | .opacity(1 - visibleHeaderRatio) 37 | cover 38 | .padding(.bottom, bottomPadding) 39 | } 40 | .clipped() 41 | } 42 | } 43 | } 44 | 45 | private extension Examples.Spotify.AlbumScreen.Header { 46 | 47 | var cover: some View { 48 | AsyncImage( 49 | url: URL(string: album.releaseCoverUrl), 50 | content: { image in 51 | image.image?.resizable() 52 | .aspectRatio(contentMode: .fit) 53 | } 54 | ) 55 | .aspectRatio(1, contentMode: .fit) 56 | .cornerRadius(5) 57 | .shadow(radius: 10) 58 | .rotation3DEffect(.degrees(rotationDegrees), axis: (x: 1, y: 0, z: 0)) 59 | .offset(y: verticalOffset) 60 | .opacity(visibleHeaderRatio) 61 | .padding(.top, 60) 62 | .padding(.bottom, 20) 63 | .padding(.horizontal, 20) 64 | } 65 | 66 | var rotationDegrees: CGFloat { 67 | guard visibleHeaderRatio > 1 else { return 0 } 68 | let value = 20.0 * (1 - visibleHeaderRatio) 69 | return value.capped(to: -5...0) 70 | } 71 | 72 | var verticalOffset: CGFloat { 73 | guard visibleHeaderRatio < 1 else { return 0 } 74 | return 70.0 * (1 - visibleHeaderRatio) 75 | } 76 | } 77 | 78 | private extension CGFloat { 79 | 80 | func capped(to range: ClosedRange) -> Self { 81 | if self < range.lowerBound { return range.lowerBound } 82 | if self > range.upperBound { return range.upperBound } 83 | return self 84 | } 85 | } 86 | 87 | #Preview { 88 | 89 | Examples.Spotify.AlbumScreen.Header(album: .anthrax) 90 | .frame(height: Examples.Spotify.AlbumScreen.Header.height) 91 | } 92 | -------------------------------------------------------------------------------- /Sources/ScrollKit/Examples/Examples.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Examples.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2025-04-09. 6 | // Copyright © 2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This is a namespace with various examples on how you can 12 | /// use this library to achieve certain effects. 13 | /// 14 | /// For now, only ` 15 | public struct Examples {} 16 | -------------------------------------------------------------------------------- /Sources/ScrollKit/Extensions/View+RoundedScollContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+RoundedScollContent.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2025-04-09. 6 | // Copyright © 2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension View { 12 | 13 | /// Make a scroll view's content view overlap the scroll 14 | /// header, by applying a negative offset. 15 | /// 16 | /// Do not use this together with a sticky header, since 17 | /// a sticky header will overlap the content, which will 18 | /// ruin this overlap effect. 19 | /// 20 | /// - Parameters: 21 | /// - points: The number of points to overlap, by default `10`. 22 | func scrollViewContentWithHeaderOverlap( 23 | _ points: Double? = nil 24 | ) -> some View { 25 | self.offset(y: -(points ?? 10)) 26 | } 27 | } 28 | 29 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) 30 | public extension View { 31 | 32 | /// Make a scroll view's content view overlap the scroll 33 | /// header with rounded corners. 34 | /// 35 | /// Do not use this together with a sticky header, since 36 | /// a sticky header will overlap the content, which will 37 | /// ruin this overlap effect. 38 | /// 39 | /// - Parameters: 40 | /// - points: The number of points to overlap, by default `10`. 41 | /// - radius: The top corner radius, by default `8`. 42 | func scrollViewContentWithRoundedHeaderOverlap( 43 | _ overlap: Double? = nil, 44 | cornerRadius radius: CGFloat = 8 45 | ) -> some View { 46 | self.background(.background) 47 | .clipShape(UnevenRoundedRectangle(topLeadingRadius: radius, topTrailingRadius: radius)) 48 | .frame(maxHeight: .infinity) 49 | .scrollViewContentWithHeaderOverlap(overlap) 50 | } 51 | 52 | /// Make a scroll view header view apply rounded corners 53 | /// that cut out a mask for the scroll view content view. 54 | /// 55 | /// - Parameters: 56 | /// - cornerRadius: The number of points to overlap, by default `0`. 57 | @ViewBuilder 58 | func scrollViewHeaderWithRoundedContentCorners( 59 | cornerRadius: Double = 0 60 | ) -> some View { 61 | if cornerRadius > 0 { 62 | self.mask { 63 | VStack(spacing: 0) { 64 | /// Make the black color overflow waaaay up. 65 | Color.black.scaleEffect(100, anchor: .bottom) 66 | ZStack { 67 | Color.white 68 | UnevenRoundedRectangle( 69 | topLeadingRadius: cornerRadius, 70 | topTrailingRadius: cornerRadius 71 | ) 72 | .fill(.black) 73 | } 74 | .compositingGroup() 75 | .luminanceToAlpha() 76 | .frame(height: cornerRadius) 77 | } 78 | } 79 | } else { 80 | self 81 | } 82 | } 83 | } 84 | 85 | #Preview { 86 | 87 | func previewHeader() -> some View { 88 | Color.red 89 | } 90 | 91 | return ScrollViewWithStickyHeader( 92 | .vertical, 93 | header: previewHeader, 94 | headerHeight: 250, 95 | headerMinHeight: 150, 96 | contentCornerRadius: 20, 97 | showsIndicators: false 98 | ) { 99 | LazyVStack { 100 | ForEach(1...100, id: \.self) { 101 | Text("\($0)") 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Sources/ScrollKit/Helpers/ScrollManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollManager.swift 3 | // ScrollKit 4 | // 5 | // Created by Gabriel Ribeiro on 2025-04-06. 6 | // Copyright © 2023-2024 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This class can be used for programmatic scrolling within 12 | /// a scroll view. 13 | /// 14 | /// This class can be used to scroll to any specific part of 15 | /// a scroll view (e.g. the header or the main content) with 16 | /// a `ScrollViewProxy`. Simply add a ``ScrollTarget`` ID to 17 | /// your scroll view's header view and content then call any 18 | /// manager instance's ``setProxy(_:)`` with the scroll view 19 | /// proxy from any scroll view reader in your view. 20 | /// 21 | /// Once everything's done, you can use ``scroll(to:anchor:)`` 22 | /// to scroll to any defined targets within your scroll view. 23 | /// 24 | /// The ``ScrollViewWithStickyHeader`` has support for using 25 | /// this manager, but you can add it to any custom view. 26 | /// 27 | /// - Important: The manager uses a `ScrollViewReader` under 28 | /// the hood, so yoyr scroll view must apply valid `.id(...)` 29 | /// values to the header and content. 30 | public class ScrollManager { 31 | 32 | /// Creates a new scroll manager instance. 33 | public init() { } 34 | 35 | /// The currently configured scroll view proxy, if any. 36 | public private(set) var proxy: ScrollViewProxy? 37 | 38 | /// Scroll to any target within . 39 | /// 40 | /// - Parameters: 41 | /// - target: The target to scroll to. 42 | /// - anchor: The anchor point to scroll to, by default `.top`. 43 | public func scroll( 44 | to target: ScrollTarget, 45 | anchor: UnitPoint = .top 46 | ) { 47 | withAnimation { 48 | proxy?.scrollTo(target, anchor: anchor) 49 | } 50 | } 51 | 52 | /// Set the internal scroll proxy. 53 | public func setProxy(_ proxy: ScrollViewProxy) { 54 | self.proxy = proxy 55 | } 56 | 57 | /// Internal scroll target identifiers. 58 | public enum ScrollTarget: String { 59 | case header, content 60 | } 61 | } 62 | 63 | public extension View { 64 | 65 | /// Register the view as a scroll target. 66 | func scrollTarget( 67 | _ target: ScrollManager.ScrollTarget 68 | ) -> some View { 69 | self.id(target) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/ScrollKit/Helpers/ScrollViewOffsetTracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewOffsetTracker.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-12-04. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view can wrap any `ScrollView` or `List` content to 12 | /// get offset tracking working when the view is scrolled. 13 | /// 14 | /// To use this view, add it within a `ScrollView` or `List`, 15 | /// then apply ``SwiftUI/View/scrollViewOffsetTracking(action:)`` 16 | /// to the parent view, like this: 17 | /// 18 | /// ```swift 19 | /// List { 20 | /// ScrollViewOffsetTracker { 21 | /// ForEach(0...100, id: \.self) { 22 | /// Text("\($0)") 23 | /// .frame(width: 200, height: 200) 24 | /// } 25 | /// } 26 | /// } 27 | /// .scrollViewOffsetTracking { offset in 28 | /// print(offset) 29 | /// } 30 | /// ``` 31 | /// 32 | /// The offset action will trigger when the list scrolls and 33 | /// provide you with the scroll offset. 34 | public struct ScrollViewOffsetTracker: View { 35 | 36 | public init( 37 | @ViewBuilder content: @escaping () -> Content 38 | ) { 39 | self.content = content 40 | } 41 | 42 | private var content: () -> Content 43 | 44 | public var body: some View { 45 | ZStack(alignment: .top) { 46 | GeometryReader { geo in 47 | Color.clear 48 | .preference( 49 | key: ScrollOffsetPreferenceKey.self, 50 | value: geo.frame(in: .named(ScrollOffsetNamespace.namespace)).origin 51 | ) 52 | } 53 | .frame(height: 0) 54 | 55 | content() 56 | } 57 | } 58 | } 59 | 60 | public extension View { 61 | 62 | /// Add this modifier to a `ScrollView`, a `List` or any 63 | /// view that has a ``ScrollViewOffsetTracker`` to track 64 | /// its scroll offset. 65 | func scrollViewOffsetTracking( 66 | action: @escaping @MainActor @Sendable (_ offset: CGPoint) -> Void 67 | ) -> some View { 68 | self.coordinateSpace(name: ScrollOffsetNamespace.namespace) 69 | .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in 70 | DispatchQueue.main.async { 71 | action(offset) 72 | } 73 | } 74 | } 75 | } 76 | 77 | 78 | enum ScrollOffsetNamespace { 79 | 80 | static let namespace = "scrollView" 81 | } 82 | 83 | struct ScrollOffsetPreferenceKey: PreferenceKey { 84 | 85 | static var defaultValue: CGPoint { .zero } 86 | 87 | static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {} 88 | } 89 | 90 | #Preview { 91 | 92 | struct Preview: View { 93 | 94 | @State 95 | private var offset = CGPoint.zero 96 | 97 | var body: some View { 98 | ScrollView(.vertical) { 99 | ScrollViewOffsetTracker { 100 | VStack { 101 | ForEach(0...100, id: \.self) { 102 | Text("\($0)") 103 | .frame(width: 200, height: 200) 104 | .background(Color.red) 105 | } 106 | } 107 | } 108 | } 109 | .scrollViewOffsetTracking { offset in 110 | let roundedX = offset.x.rounded() 111 | let roundedY = offset.y.rounded() 112 | self.offset = .init(x: roundedX, y: roundedY) 113 | } 114 | .navigationTitle("\(offset.debugDescription)") 115 | } 116 | } 117 | 118 | return NavigationView { 119 | #if os(macOS) 120 | Color.clear 121 | #endif 122 | Preview() 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollKit.docc/Articles/Getting-Started-Article.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ScrollKit adds powerful scrolling features to SwiftUI, such as offset tracking and a sticky scroll header. 4 | 5 | @Metadata { 6 | 7 | @PageImage( 8 | purpose: card, 9 | source: "Page", 10 | alt: "Page icon" 11 | ) 12 | 13 | @PageColor(blue) 14 | } 15 | 16 | 17 | ## Basic Usage 18 | 19 | The most basic use-case is to use the top-level ``ScrollViewWithStickyHeader`` to set up a sticky header within any list or scroll view, then use ``ScrollViewHeaderImage`` and ``ScrollViewHeaderGradient`` to easily manage the stretching. 20 | 21 | There are however many other scroll utilities in this library. Some have been replaced by native SwiftUI features that have been added after this library was first released, but are kept due to backwards compatibility. 22 | 23 | 24 | 25 | ## How to set up a scroll view with a sticky header 26 | 27 | You can use the ``ScrollViewWithStickyHeader`` view to create a scroll view that has a header view that stretches and transforms when it's pulled down, and sticks to the top as the scroll view content is scrolled: 28 | 29 | ```swift 30 | struct MyView: View { 31 | 32 | @State 33 | private var offset = CGPoint.zero 34 | 35 | @State 36 | private var visibleRatio = CGFloat.zero 37 | 38 | var body: some View { 39 | ScrollViewWithStickyHeader( 40 | header: stickyHeader, // A header view 41 | headerHeight: 250, // The resting header height 42 | headerMinHeight: 150, // The minimum header height 43 | headerStretch: false, // Disables the stretch effect 44 | contentCornerRadius: 20 // An optional corner radius mask 45 | onScroll: handleScroll // An optional scroll handler action 46 | ) { 47 | // Add your scroll content here, e.g. a `LazyVStack` 48 | } 49 | } 50 | 51 | func handleScroll(_ offset: CGPoint, visibleHeaderRatio: CGFloat) { 52 | self.scrollOffset = offset 53 | self.visibleRatio = visibleHeaderRatio 54 | } 55 | 56 | func stickyHeader() -> some View { 57 | ZStack { 58 | Color.red 59 | ScrollViewHeaderGradient() // By default a dark gradient 60 | Text("Scroll offset: \(offset.y)") 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | The visibleHeaderRatio is how many percent (0-1) that is visible below the navigation bar. You can use this to adjust the header content. 67 | 68 | 69 | 70 | ## How to track scroll offset 71 | 72 | Althouth there are native alternatives, ScrollKit has a ``ScrollViewWithOffsetTracking`` that triggers an action when it's scrolled: 73 | 74 | ```swift 75 | struct MyView: View { 76 | 77 | @State 78 | private var offset = CGPoint.zero 79 | 80 | func handleOffset(_ scrollOffset: CGPoint) { 81 | self.offset = scrollOffset 82 | } 83 | 84 | var body: some View { 85 | ScrollViewWithOffsetTracking(onScroll: handleOffset) { 86 | // Add your scroll content here, e.g. a `LazyVStack` 87 | } 88 | } 89 | } 90 | ``` 91 | 92 | You can also use the ``ScrollViewOffsetTracker`` together with the ``SwiftUICore/View/scrollViewOffsetTracking(action:)`` view modifier: 93 | 94 | ```swift 95 | List { 96 | ScrollViewOffsetTracker { 97 | ForEach(0...100, id: \.self) { 98 | Text("\($0)") 99 | .frame(width: 200, height: 200) 100 | } 101 | } 102 | } 103 | .scrollViewOffsetTracking { offset in 104 | print(offset) 105 | } 106 | ``` 107 | 108 | You use the offset in any way you like, e.g. to fade navigation bar title. This is how ``ScrollViewWithStickyHeader``, which also provides you with the scroll offset, is implemented. 109 | 110 | 111 | 112 | ## How to trigger scrolling with code 113 | 114 | You can use the ``ScrollManager`` to scroll to certain parts of a scroll view. See the documentation on how to set it up. 115 | 116 | The ``ScrollViewWithStickyHeader`` applies the propert header and content IDs, and lets you inject a manager and use it to scroll within the scroll view. 117 | 118 | 119 | 120 | ## How to fade in the status bar on scroll 121 | 122 | Since it's complicated to control the appearance of a status bar in an app that supports both light and dark mode, and there are some glitches when the scroll offset is zero, ScrollKit has ways to hide the status bar until the view scrolls. 123 | 124 | Just add a ``StatusBarVisibleState`` to your view, and apply a ``SwiftUICore/View/statusBarVisible(_:)`` view modifier to the root content. The status bar will then automatically update as you scroll. 125 | 126 | 127 | ```swift 128 | struct ContentView: View { 129 | 130 | @StateObject 131 | private var state = StatusBarVisibleState() 132 | 133 | var body: some View { 134 | NavigationStack { 135 | ... 136 | } 137 | .statusBarVisible(state) 138 | } 139 | } 140 | ``` 141 | 142 | You can also just use the ``SwiftUICore/View/statusBarHiddenUntilScrolled(offset:)`` modifier, which will automatically set up everything and handle the state on scroll. 143 | 144 | Note that this is an experimental feature that may contain glitches based on where you use it. Please report any strange behavior if you find that this utility doesn't work as intended in certain scenarios. 145 | 146 | 147 | 148 | ## Additional view utilities 149 | 150 | ScrollKit has more views and view extensions that can be used as standalone features. 151 | 152 | ### Views 153 | 154 | The ``ScrollViewHeader`` can be used as a header view within a scroll view and will stretch out when pulled down, then scroll away with the content. The ``ScrollViewHeaderGradient`` is a discrete color gradient that can be used improve readability when text is added above a light image. The ``ScrollViewHeaderImage`` takes a custom image and adjusts it to be presented as a stretchy scroll view header, by changing its aspect ratio. 155 | 156 | 157 | ## View extensions 158 | 159 | There are many ``SwiftUICore/View`` extensions within the library, that are used by the various view components, but that can be used as standalone features as well. Some examples are ``SwiftUICore/View/hideStatusBarUntilScrolled(using:)``, which can be used to hide the status bar based on an observable value, ``SwiftUICore/View/scrollViewContentWithRoundedHeaderOverlap(_:cornerRadius:)``, which lets a view overlap a static header view with rounded corners, and ``SwiftUICore/View/scrollViewHeaderWithRoundedContentCorners(cornerRadius:)`` which is used by ``ScrollViewWithStickyHeader`` to create a rounded, inverse content mask under which the scroll view content scrolls. 160 | -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollKit.docc/Resources/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ScrollKit/874399d2431ef57d365e985f3088c9f5f243efb1/Sources/ScrollKit/ScrollKit.docc/Resources/Logo.png -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollKit.docc/Resources/Page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ScrollKit/874399d2431ef57d365e985f3088c9f5f243efb1/Sources/ScrollKit/ScrollKit.docc/Resources/Page.png -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollKit.docc/Resources/Rounded-Corners.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/ScrollKit/874399d2431ef57d365e985f3088c9f5f243efb1/Sources/ScrollKit/ScrollKit.docc/Resources/Rounded-Corners.jpg -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollKit.docc/ScrollKit.md: -------------------------------------------------------------------------------- 1 | # ``ScrollKit`` 2 | 3 | ScrollKit is a Swift SDK that lets adds powerful scroll features to SwiftUI. 4 | 5 | 6 | 7 | ## Overview 8 | 9 | ![Library logotype](Logo.png) 10 | 11 | ScrollKit is a SwiftUI library that adds powerful scroll features, like offset tracking and a header view that stretches & transforms as you pull down, and sticks to the top when you scroll. 12 | 13 | ScrollKit works on all major Apple platforms and is designed to be easy to use. It doesn't use the new ScrollView APIs for backwards compatibility reasons, but will eventually do so. 14 | 15 | 16 | 17 | ## Installation 18 | 19 | ScrollKit can be installed with the Swift Package Manager: 20 | 21 | ``` 22 | https://github.com/danielsaidi/ScrollKit.git 23 | ``` 24 | 25 | 26 | ## Support My Work 27 | 28 | You can [become a sponsor][Sponsors] to help me dedicate more time on my various [open-source tools][OpenSource]. Every contribution, no matter the size, makes a real difference in keeping these tools free and actively developed. 29 | 30 | 31 | 32 | ## Getting started 33 | 34 | The article helps you get started with ScrollKit. 35 | 36 | 37 | 38 | ## Repository 39 | 40 | For more information, source code, etc., visit the [project repository](https://github.com/danielsaidi/ScrollKit). 41 | 42 | 43 | 44 | ## License 45 | 46 | ScrollKit is available under the MIT license. 47 | 48 | 49 | 50 | ## Topics 51 | 52 | ### Articles 53 | 54 | - 55 | 56 | ### Essentials 57 | 58 | - ``ScrollViewWithStickyHeader`` 59 | - ``ScrollViewWithOffsetTracking`` 60 | 61 | ### Views 62 | 63 | - ``ScrollViewHeader`` 64 | - ``ScrollViewHeaderGradient`` 65 | - ``ScrollViewHeaderImage`` 66 | 67 | ### Helpers 68 | 69 | - ``ScrollViewOffsetTracker`` 70 | 71 | ### Examples 72 | 73 | - ``Examples`` 74 | 75 | 76 | 77 | [Email]: mailto:daniel.saidi@gmail.com 78 | [Website]: https://danielsaidi.com 79 | [GitHub]: https://github.com/danielsaidi 80 | [OpenSource]: https://danielsaidi.com/opensource 81 | [Sponsors]: https://github.com/sponsors/danielsaidi 82 | -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollViewHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewHeader.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2022-10-13. 6 | // Copyright © 2022 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view can be used as a scroll view header, that will 12 | /// automatically stretch its content when pulled down. 13 | /// 14 | /// For instance, this creates a header view with a gradient 15 | /// background, a gradient overlay and a bottom-leading text: 16 | /// 17 | /// ```swift 18 | /// struct MyHeader: View { 19 | /// 20 | /// var body: some View { 21 | /// ScrollViewHeader { 22 | /// ZStack(alignment: .bottomLeading) { 23 | /// LinearGradient( 24 | /// colors: [.blue, .yellow], 25 | /// startPoint: .topLeading, 26 | /// endPoint: .bottomTrailing 27 | /// ) 28 | /// LinearGradient( 29 | /// colors: [.clear, .black.opacity(0.6)], 30 | /// startPoint: .top, 31 | /// endPoint: .bottom 32 | /// ) 33 | /// Text("Header title") 34 | /// .padding() 35 | /// } 36 | /// .frame(height: 250) 37 | /// } 38 | /// } 39 | /// ``` 40 | /// 41 | /// To add the view to a scroll view with more content below 42 | /// the header, just add the header topmost in a `VStack`: 43 | /// 44 | /// ```swift 45 | /// ScrollView(.vertical) { 46 | /// VStack(spacing: 0) { 47 | /// MyHeader() 48 | /// // More content here 49 | /// } 50 | /// } 51 | /// ``` 52 | /// 53 | /// Your header view will now automatically stretch out when 54 | /// the scroll view is pulled down. 55 | public struct ScrollViewHeader: View { 56 | 57 | /// Create a stretchable scroll view header. 58 | public init( 59 | @ViewBuilder content: @escaping () -> Content 60 | ) { 61 | self.content = content 62 | } 63 | 64 | private let content: () -> Content 65 | 66 | public var body: some View { 67 | GeometryReader { geo in 68 | content() 69 | .stretchable(in: geo) 70 | } 71 | } 72 | } 73 | 74 | private extension View { 75 | 76 | @ViewBuilder 77 | func stretchable(in geo: GeometryProxy) -> some View { 78 | let width = geo.size.width 79 | let height = geo.size.height 80 | let minY = geo.frame(in: .global).minY 81 | let useStandard = minY <= 0 82 | self.frame(width: width, height: height + (useStandard ? 0 : minY)) 83 | .offset(y: useStandard ? 0 : -minY) 84 | } 85 | } 86 | 87 | #Preview { 88 | 89 | struct Preview: View { 90 | 91 | var body: some View { 92 | #if canImport(UIKit) 93 | NavigationView { 94 | content 95 | } 96 | .accentColor(.white) 97 | .colorScheme(.dark) 98 | #else 99 | content 100 | .accentColor(.white) 101 | .colorScheme(.dark) 102 | #endif 103 | } 104 | 105 | var content: some View { 106 | ScrollView { 107 | VStack { 108 | ScrollViewHeader { 109 | TabView { 110 | Color.red 111 | Color.green 112 | Color.blue 113 | } 114 | #if canImport(UIKit) 115 | .tabViewStyle(.page) 116 | #endif 117 | } 118 | .frame(height: 250) 119 | 120 | LazyVStack { 121 | ForEach(0...100, id: \.self) { 122 | Text("\($0)") 123 | } 124 | } 125 | } 126 | } 127 | .navigationTitle("Test") 128 | #if os(iOS) 129 | .navigationBarTitleDisplayMode(.inline) 130 | #endif 131 | } 132 | } 133 | 134 | return Preview() 135 | } 136 | -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollViewHeaderGradient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewHeaderGradient.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-04. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view can be used as a header view overlay, when the 12 | /// background may cause the content to be hard to overview. 13 | /// 14 | /// This is basically just a convenience, to avoid having to 15 | /// use a `LinearGradient` from scratch. 16 | public struct ScrollViewHeaderGradient: View { 17 | 18 | /// Create a scroll view header gradient. 19 | /// 20 | /// - Parameters: 21 | /// - startColor: The top start color, by default `.clear`. 22 | /// - endColor: The bottom end color, by default semi-black. 23 | public init( 24 | _ startColor: Color = .clear, 25 | _ endColor: Color? = nil 26 | ) { 27 | self.init(startColor, .top, endColor, .bottom) 28 | } 29 | 30 | /// Create a scroll view header gradient. 31 | /// 32 | /// - Parameters: 33 | /// - startColor: The top start color, by default `.clear`. 34 | /// - startPoint: The top start point. 35 | /// - endColor: The bottom end color, by default semi-black. 36 | /// - endPoint: The top start point. 37 | public init( 38 | _ startColor: Color = .clear, 39 | _ startPoint: UnitPoint, 40 | _ endColor: Color? = nil, 41 | _ endPoint: UnitPoint 42 | ) { 43 | self.startColor = startColor 44 | self.startPoint = startPoint 45 | self.endColor = endColor ?? .black.opacity(0.4) 46 | self.endPoint = endPoint 47 | } 48 | 49 | private let startColor: Color 50 | private let startPoint: UnitPoint 51 | private let endColor: Color 52 | private let endPoint: UnitPoint 53 | 54 | public var body: some View { 55 | LinearGradient( 56 | colors: [startColor, endColor], 57 | startPoint: startPoint, 58 | endPoint: endPoint 59 | ) 60 | } 61 | } 62 | 63 | #Preview { 64 | 65 | VStack { 66 | ScrollViewHeaderGradient() 67 | ScrollViewHeaderGradient(.blue, .topLeading, .yellow, .bottom) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollViewHeaderImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewHeaderImage.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-04. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view takes any image and adjusts it to be presented 12 | /// as a scroll view header. 13 | /// 14 | /// This view makes sure that your image stretches correctly 15 | /// and that it clips to the available space. 16 | public struct ScrollViewHeaderImage: View { 17 | 18 | /// Create a scroll view header image. 19 | /// 20 | /// - Parameters: 21 | /// - image: The image to wrap. 22 | public init(_ image: Image) { 23 | self.image = image 24 | } 25 | 26 | private let image: Image 27 | 28 | public var body: some View { 29 | Color.clear.background( 30 | image 31 | .resizable() 32 | .aspectRatio(contentMode: .fill) 33 | ) 34 | .clipped() 35 | } 36 | } 37 | 38 | #Preview { 39 | 40 | ScrollViewHeaderImage( 41 | Image(systemName: "checkmark") 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollViewWithOffsetTracking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewWithOffsetTracking.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-03. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view wraps a native scroll view, and will track its 12 | /// scroll offset as it's scrolled. 13 | /// 14 | /// You can use the `onScroll` init parameter to pass in any 15 | /// function that should be called whenever the view scrolls. 16 | public struct ScrollViewWithOffsetTracking: View { 17 | 18 | /// Create a scroll view with offset tracking. 19 | /// 20 | /// - Parameters: 21 | /// - axes: The scroll axes to use, by default `.vertical`. 22 | /// - showsIndicators: Whether or not to show scroll indicators, by default `true`. 23 | /// - onScroll: An action that will be called whenever the scroll offset changes, by default `nil`. 24 | /// - content: The scroll view content. 25 | public init( 26 | _ axes: Axis.Set = .vertical, 27 | showsIndicators: Bool = true, 28 | onScroll: ScrollAction? = nil, 29 | @ViewBuilder content: @escaping () -> Content 30 | ) { 31 | self.axes = axes 32 | self.showsIndicators = showsIndicators 33 | self.onScroll = onScroll ?? { _ in } 34 | self.content = content 35 | } 36 | 37 | private let axes: Axis.Set 38 | private let showsIndicators: Bool 39 | private let onScroll: ScrollAction 40 | private let content: () -> Content 41 | 42 | public typealias ScrollAction = @MainActor @Sendable (_ offset: CGPoint) -> Void 43 | 44 | public var body: some View { 45 | ScrollView(axes, showsIndicators: showsIndicators) { 46 | ScrollViewOffsetTracker { 47 | content() 48 | } 49 | } 50 | .scrollViewOffsetTracking(action: onScroll) 51 | } 52 | } 53 | 54 | #Preview { 55 | 56 | struct Preview: View { 57 | 58 | @State 59 | var scrollOffset: CGPoint = .zero 60 | 61 | var body: some View { 62 | NavigationView { 63 | #if os(macOS) 64 | Color.clear 65 | #endif 66 | ScrollViewWithOffsetTracking(onScroll: updateScrollOffset) { 67 | LazyVStack { 68 | ForEach(1...100, id: \.self) { 69 | Divider() 70 | Text("\($0)") 71 | } 72 | } 73 | } 74 | .navigationTitle("\(Int(scrollOffset.y))") 75 | } 76 | } 77 | 78 | func updateScrollOffset(_ offset: CGPoint) { 79 | self.scrollOffset = offset 80 | } 81 | } 82 | 83 | return Preview() 84 | } 85 | -------------------------------------------------------------------------------- /Sources/ScrollKit/ScrollViewWithStickyHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollViewWithStickyHeader.swift 3 | // ScrollKit 4 | // 5 | // Created by Daniel Saidi on 2023-02-03. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This scroll view lets you inject a header view that will 12 | /// stick to the top when the view scrolls. 13 | /// 14 | /// The view wraps a ``ScrollViewWithOffsetTracking`` to get 15 | /// scroll offset that it then uses to determine how much of 16 | /// the header that's below the navigation bar. It also uses 17 | /// a ``ScrollViewHeader`` to make your header view properly 18 | /// stretch out when the scroll view is pulled down. 19 | /// 20 | /// You can apply a `headerHeight` which will be the resting 21 | /// height of the header, a `headerMinHeight` as the minimum 22 | /// header height (below the top safe area), and an optional 23 | /// `contentCornerRadius` which applies a corner radius mask 24 | /// under which the scroll view content will scroll. You can 25 | /// also set the `headerStretch` parameter to `false` if you 26 | /// prefer to disable the header stretch effect. This can be 27 | /// nice when the view is rendered in a sheet, where pulling 28 | /// down should dismiss the sheet rather than stretching the 29 | /// sticky header. 30 | /// 31 | /// You can use the `onScroll` init parameter to pass in any 32 | /// function that should be called whenever the view scrolls. 33 | /// The function is called with the scroll view offset and a 34 | /// "header visible ratio", which indicates how much of your 35 | /// header that is visible below the navigation bar. 36 | /// 37 | /// This view will automatically use an inline title display 38 | /// mode, since it doesn't work for a large nativation title. 39 | /// 40 | /// > Important: `toolbarBackground(.hidden)` is applied for 41 | /// iOS 16 and later, to make the navigation bar transparent. 42 | /// It's not applied on iOS 15 and earlier, which means that 43 | /// you must use another way to make the bar transparent for 44 | /// older iOS versions. One way is to use appearance proxies 45 | /// if you can fall down to UIKit. 46 | public struct ScrollViewWithStickyHeader: View { 47 | 48 | /// Create a scroll view with a sticky header. 49 | /// 50 | /// - Parameters: 51 | /// - axes: The scroll axes to use, by default `.vertical`. 52 | /// - header: The scroll view header builder. 53 | /// - headerHeight: The height to apply to the scroll view header. 54 | /// - headerMinHeight: The minimum height to apply to the scroll view header, by default the `top safe area insets`. 55 | /// - headerStretch: Whether to stretch out the header when pulling down, by default `true`. 56 | /// - contentCornerRadius: The corner radius to apply to the scroll content. 57 | /// - showsIndicators: Whether or not to show scroll indicators, by default `true`. 58 | /// - scrollManager: A class that manages programmatic scrolling to header or content. 59 | /// - onScroll: An action that will be called whenever the scroll offset changes, by default `nil`. 60 | /// - content: The scroll view content builder. 61 | public init( 62 | _ axes: Axis.Set = .vertical, 63 | @ViewBuilder header: @escaping () -> Header, 64 | headerHeight: Double, 65 | headerMinHeight: Double? = nil, 66 | headerStretch: Bool = true, 67 | contentCornerRadius: CGFloat = 0, 68 | showsIndicators: Bool = true, 69 | scrollManager: ScrollManager? = nil, 70 | onScroll: ScrollAction? = nil, 71 | @ViewBuilder content: @escaping () -> Content 72 | ) { 73 | self.axes = axes 74 | self.showsIndicators = showsIndicators 75 | self.header = header 76 | self.headerHeight = headerHeight 77 | self.headerMinHeight = headerMinHeight 78 | self.headerStretch = headerStretch 79 | self.contentCornerRadius = contentCornerRadius 80 | self.scrollManager = scrollManager 81 | self.onScroll = onScroll 82 | self.content = content 83 | } 84 | 85 | private let axes: Axis.Set 86 | private let showsIndicators: Bool 87 | private let header: () -> Header 88 | private let headerHeight: Double 89 | private let headerMinHeight: Double? 90 | private let headerStretch: Bool 91 | private let contentCornerRadius: CGFloat 92 | private let scrollManager: ScrollManager? 93 | private let onScroll: ScrollAction? 94 | private let content: () -> Content 95 | 96 | public typealias ScrollAction = (_ offset: CGPoint, _ visibleHeaderRatio: CGFloat) -> Void 97 | 98 | @State 99 | private var scrollOffset: CGPoint = .zero 100 | 101 | private var visibleHeaderRatio: CGFloat { 102 | let value = (headerHeight + scrollOffset.y) / headerHeight 103 | if headerStretch { return value } 104 | return min(1, value) 105 | } 106 | 107 | public var body: some View { 108 | GeometryReader { geo in 109 | ZStack(alignment: .top) { 110 | scrollView(in: geo) 111 | navbarOverlay(in: geo) 112 | } 113 | .edgesIgnoringSafeArea(.all) 114 | } 115 | .prefersNavigationBarHidden() 116 | #if os(iOS) 117 | .navigationBarTitleDisplayMode(.inline) 118 | #endif 119 | } 120 | } 121 | 122 | @MainActor 123 | private extension ScrollViewWithStickyHeader { 124 | 125 | func headerMinHeight( 126 | in geo: GeometryProxy 127 | ) -> Double { 128 | let minHeight = headerMinHeight ?? 0 129 | let safeMinHeight = minHeight + geo.safeAreaInsets.top 130 | return min(safeMinHeight, headerHeight) 131 | } 132 | 133 | func isStickyHeaderVisible( 134 | in geo: GeometryProxy 135 | ) -> Bool { 136 | let minHeight = headerMinHeight(in: geo) 137 | return scrollOffset.y < -minHeight 138 | } 139 | 140 | func navbarOverlay( 141 | in geo: GeometryProxy 142 | ) -> some View { 143 | let minHeight = headerMinHeight(in: geo) 144 | let ratioHeight = headerHeight * visibleHeaderRatio 145 | return Color.clear.overlay(alignment: .bottom) { 146 | scrollHeader 147 | } 148 | .frame(height: max(minHeight, ratioHeight)) 149 | .ignoresSafeArea(edges: .top) 150 | } 151 | 152 | func scrollView( 153 | in geo: GeometryProxy 154 | ) -> some View { 155 | ScrollViewReader { scrollProxy in 156 | ScrollViewWithOffsetTracking( 157 | axes, 158 | showsIndicators: showsIndicators, 159 | onScroll: handleScrollOffset 160 | ) { 161 | VStack(spacing: 0) { 162 | scrollHeader 163 | .opacity(0) 164 | .scrollTarget(.header) 165 | content() 166 | .frame(maxHeight: .infinity) 167 | .scrollTarget(.content) 168 | } 169 | } 170 | .onAppear { 171 | scrollManager?.setProxy(scrollProxy) 172 | } 173 | } 174 | } 175 | 176 | @ViewBuilder 177 | var scrollHeader: some View { 178 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 179 | scrollHeaderView 180 | .scrollViewHeaderWithRoundedContentCorners( 181 | cornerRadius: contentCornerRadius 182 | ) 183 | } else { 184 | scrollHeaderView 185 | } 186 | } 187 | 188 | @ViewBuilder 189 | var scrollHeaderView: some View { 190 | ScrollViewHeader(content: header) 191 | .frame(minHeight: headerHeight) 192 | .edgesIgnoringSafeArea(.all) 193 | } 194 | 195 | func handleScrollOffset(_ offset: CGPoint) { 196 | self.scrollOffset = offset 197 | self.onScroll?(offset, visibleHeaderRatio) 198 | } 199 | } 200 | 201 | #Preview("Demo") { 202 | 203 | func previewHeader() -> some View { 204 | Color.red 205 | } 206 | 207 | return ScrollViewWithStickyHeader( 208 | header: previewHeader, 209 | headerHeight: 200, 210 | headerMinHeight: nil 211 | ) { 212 | LazyVStack(spacing: 0) { 213 | ForEach(1...100, id: \.self) { item in 214 | VStack(spacing: 0) { 215 | Text("Item \(item)") 216 | .padding() 217 | .frame(maxWidth: .infinity, alignment: .leading) 218 | Divider() 219 | } 220 | } 221 | } 222 | } 223 | } 224 | 225 | #Preview("Navigation") { 226 | 227 | return NavigationView { 228 | #if os(macOS) 229 | Color.clear 230 | #endif 231 | Preview(isInSheet: false) 232 | } 233 | #if os(iOS) 234 | .navigationViewStyle(.stack) 235 | #endif 236 | } 237 | 238 | #Preview("Sheet") { 239 | 240 | struct SheetPreview: View { 241 | 242 | @State var isPresented = true 243 | 244 | var body: some View { 245 | Button("Present") { 246 | isPresented.toggle() 247 | } 248 | .sheet(isPresented: $isPresented) { 249 | Preview(isInSheet: true) 250 | } 251 | } 252 | } 253 | 254 | return SheetPreview() 255 | } 256 | 257 | private struct Preview: View { 258 | 259 | let isInSheet: Bool 260 | 261 | @State var visibleHeaderRatio = 0.0 262 | @State var scrollOffset = CGPoint.zero 263 | 264 | let contentCornerRadius = 20.0 265 | 266 | func header() -> some View { 267 | #if canImport(UIKit) 268 | TabView { 269 | Group { 270 | headerPageView(.red).tag(0) 271 | headerPageView(.green).tag(1) 272 | headerPageView(.blue).tag(2) 273 | } 274 | .edgesIgnoringSafeArea(.all) 275 | } 276 | .tabViewStyle(.page) 277 | #else 278 | VStack { 279 | Color.blue 280 | Color.red 281 | } 282 | #endif 283 | } 284 | 285 | func headerPageView( 286 | _ color: Color 287 | ) -> some View { 288 | LinearGradient( 289 | colors: [color, .black], 290 | startPoint: .top, 291 | endPoint: .bottom 292 | ) 293 | } 294 | 295 | var body: some View { 296 | ScrollViewWithStickyHeader( 297 | .vertical, 298 | header: header, 299 | headerHeight: 250, 300 | headerMinHeight: 100, 301 | headerStretch: false, 302 | contentCornerRadius: 10, // contentCornerRadius, 303 | showsIndicators: false, 304 | onScroll: handleScroll 305 | ) { 306 | LazyVStack { 307 | ForEach(1...100, id: \.self) { 308 | Text("\($0)") 309 | } 310 | } 311 | } 312 | .overlay { 313 | VStack { 314 | Text("Offset: \(scrollOffset.y)") 315 | Text("Ratio: \(visibleHeaderRatio)") 316 | } 317 | .background(Color.yellow) 318 | } 319 | } 320 | 321 | func handleScroll( 322 | offset: CGPoint, 323 | visibleHeaderRatio: CGFloat 324 | ) { 325 | self.scrollOffset = offset 326 | self.visibleHeaderRatio = visibleHeaderRatio 327 | } 328 | } 329 | 330 | private extension View { 331 | 332 | @ViewBuilder 333 | func prefersNavigationBarHidden() -> some View { 334 | #if os(watchOS) 335 | self 336 | #else 337 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) { 338 | self.toolbarBackground(.hidden) 339 | } else { 340 | self 341 | } 342 | #endif 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /Sources/ScrollKit/StatusBar/StatusBarVisibileState.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(visionOS) 2 | // 3 | // StatusBarVisibleState.swift 4 | // ScrollKit 5 | // 6 | // Created by Daniel Saidi on 2023-03-13. 7 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 8 | // 9 | 10 | import SwiftUI 11 | 12 | /// This class can be used to manage the visibility state of 13 | /// the main status bar across a view hierarchy. 14 | /// 15 | /// To use this class, just create a `@StateObject` instance 16 | /// and apply it to any view with the `.statusBarVisibile(_)` 17 | /// view modifier. This will inject `.statusBar(hidden:)` on 18 | /// the view using the ``isHidden`` property, and inject the 19 | /// instance as an environment object. You can then retrieve 20 | /// the instance in any view and set ``isHidden`` to show or 21 | /// hide the status bar. 22 | /// 23 | /// There are also convenience functions that let you handle 24 | /// calculations & animations with greated control, like the 25 | /// ``hide(if:ifGreaterThan:)`` which can be used to control 26 | /// when the status bar should become visible after a scroll 27 | /// event, which lets you mitigate the bad, default behavior 28 | /// in iOS, where `.toolbarColorScheme(_:for:)` only applies 29 | /// after scrolling down to fade in the navigation bar. 30 | /// 31 | /// Note that using this state class with a `NavigationStack` 32 | /// requires that you apply it to the stack itself: 33 | /// 34 | /// ```swift 35 | /// struct ContentView: View { 36 | /// 37 | /// @StateObject 38 | /// private var state = StatusBarVisibleState() 39 | /// 40 | /// var body: some View { 41 | /// NavigationStack { 42 | /// ... 43 | /// } 44 | /// .statusBarVisible(state) 45 | /// } 46 | /// } 47 | /// ``` 48 | /// 49 | /// Take a look at the demo app for examples of how this can 50 | /// be used to hide the status bar until you scroll. 51 | public class StatusBarVisibleState: ObservableObject { 52 | 53 | /// Create a visibility state instance. 54 | /// 55 | /// - Parameters: 56 | /// - isHidden: Whether or not to initially hide the status bar, by default `false`. 57 | /// - isAnimated: Whether or not to animate changes, by default `false`. 58 | public init( 59 | isHidden: Bool = false, 60 | isAnimated: Bool = false 61 | ) { 62 | self.isHidden = isHidden 63 | self.isAnimated = isAnimated 64 | } 65 | 66 | /// Whether or not to initially hide the status bar. 67 | @Published 68 | public var isHidden: Bool 69 | 70 | /// Whether or not to animate changes 71 | @Published 72 | public var isAnimated: Bool 73 | } 74 | 75 | public extension StatusBarVisibleState { 76 | 77 | /// Update ``isHidden`` to become true when an offset `y` 78 | /// value is greater than a certain value. 79 | /// 80 | /// - Parameters: 81 | /// - offset: The offset to use. 82 | /// - value: An optional value that controls when the status bar should be hidden. 83 | func hide( 84 | if offset: CGPoint, 85 | ifGreaterThan value: CGFloat 86 | ) { 87 | updateIsHidden(with: offset.y > value) 88 | } 89 | 90 | /// Update ``isHidden`` to become true when an offset `y` 91 | /// value is greater than a certain value. 92 | /// 93 | /// - Parameters: 94 | /// - offset: The offset to use. 95 | /// - value: An optional value that controls when the status bar should be hidden. 96 | func hide(if offset: CGPoint, ifLessThan value: CGFloat) { 97 | updateIsHidden(with: offset.y < value) 98 | } 99 | 100 | /// Update ``isHidden`` to become true when an offset `y` 101 | /// value indicates that a view is pulled down. 102 | /// 103 | /// Note that this won't look good if a status bar use a 104 | /// light content configuration, since the light content 105 | /// isn't applied if the navigation bar isn't showing. 106 | /// 107 | /// - Parameters: 108 | /// - offset: The offset to use. 109 | func hideUntilPulled(using offset: CGPoint) { 110 | hide(if: offset, ifLessThan: 2) 111 | } 112 | 113 | /// Update ``isHidden`` to become true when an offset `y` 114 | /// value indicates that a view is scrolled. 115 | /// 116 | /// - Parameters: 117 | /// - offset: The offset to use. 118 | func hideUntilScrolled(using offset: CGPoint) { 119 | hide(if: offset, ifGreaterThan: -3) 120 | } 121 | } 122 | 123 | private extension StatusBarVisibleState { 124 | 125 | func updateIsHidden(with value: Bool) { 126 | if isAnimated { 127 | withAnimation { isHidden = value } 128 | } else { 129 | isHidden = value 130 | } 131 | } 132 | } 133 | 134 | public extension View { 135 | 136 | /// Set the status bar visibility based on a state value. 137 | func statusBarVisible(_ state: StatusBarVisibleState) -> some View { 138 | self.statusBarHidden(state.isHidden) 139 | .environmentObject(state) 140 | } 141 | 142 | /// Set the status bar visibility based on a state value. 143 | func statusBarHidden(_ state: StatusBarVisibleState) -> some View { 144 | self.statusBarVisible(state) 145 | } 146 | } 147 | #endif 148 | -------------------------------------------------------------------------------- /Sources/ScrollKit/StatusBar/StatusBarVisibilityUpdater.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(visionOS) 2 | // 3 | // StatusBarVisibilityUpdater.swift 4 | // ScrollKit 5 | // 6 | // Created by Daniel Saidi on 2023-03-14. 7 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 8 | // 9 | 10 | import SwiftUI 11 | 12 | /// This view modifier can be used to automatically keep any 13 | /// injected ``StatusBarVisibleState`` updated during scroll. 14 | /// 15 | /// For now, the easiest way to apply this view modifier, is 16 | /// with ``SwiftUICore/View/statusBarHiddenUntilScrolled(offset:)``. 17 | public struct StatusBarVisibilityUpdater: ViewModifier { 18 | 19 | public init( 20 | scrollOffset: Binding 21 | ) { 22 | _offset = scrollOffset 23 | } 24 | 25 | @Binding 26 | private var offset: CGPoint 27 | 28 | @Environment(\.presentationMode) 29 | private var presentationMode 30 | 31 | @EnvironmentObject 32 | private var state: StatusBarVisibleState 33 | 34 | public func body(content: Content) -> some View { 35 | content 36 | .onAppear { 37 | state.hideUntilScrolled(using: offset) 38 | } 39 | #if os(iOS) 40 | .onChange(of: offset) { 41 | state.hideUntilScrolled(using: $0) 42 | } 43 | .onChange(of: presentationMode.wrappedValue.isPresented) { _ in 44 | offset.y = 0 45 | state.isHidden = false 46 | } 47 | #else 48 | .onChange(of: offset) { 49 | state.hideUntilScrolled(using: $1) 50 | } 51 | .onChange(of: presentationMode.wrappedValue.isPresented) { _, _ in 52 | offset.y = 0 53 | state.isHidden = false 54 | } 55 | #endif 56 | } 57 | } 58 | 59 | @MainActor 60 | public extension View { 61 | 62 | /// Hides the status bar until the offset indicates that 63 | /// the view has been scrolled. 64 | func statusBarHiddenUntilScrolled( 65 | offset: Binding 66 | ) -> some View { 67 | self.modifier(StatusBarVisibilityUpdater(scrollOffset: offset)) 68 | } 69 | } 70 | #endif 71 | -------------------------------------------------------------------------------- /Sources/ScrollKit/_Deprecated/Spotify.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @available(*, deprecated, renamed: "Examples.Spotify") 4 | public typealias Spotify = Examples.Spotify 5 | 6 | public extension Examples.Spotify { 7 | 8 | @available(*, deprecated, renamed: "Examples.Spotify.Album") 9 | typealias PreviewInfo = Examples.Spotify.Album 10 | 11 | @available(*, deprecated, renamed: "Examples.Spotify.AlbumScreen") 12 | typealias PreviewScreen = Examples.Spotify.AlbumScreen 13 | 14 | @available(*, deprecated, renamed: "Examples.Spotify.AlbumScreen.Content") 15 | typealias PreviewScreenContent = Examples.Spotify.AlbumScreen.Content 16 | 17 | @available(*, deprecated, renamed: "Examples.Spotify.AlbumScreen.Header") 18 | typealias PreviewScreenHeader = Examples.Spotify.AlbumScreen.Header 19 | } 20 | 21 | public extension Examples.Spotify.AlbumScreen { 22 | 23 | @available(*, deprecated, renamed: "init(album:)") 24 | init(info: Examples.Spotify.PreviewInfo) { 25 | self.init(album: info) 26 | } 27 | } 28 | 29 | public extension Examples.Spotify.AlbumScreen.Content { 30 | 31 | @available(*, deprecated, renamed: "init(album:)") 32 | init(info: Examples.Spotify.PreviewInfo) { 33 | self.init(album: info) 34 | } 35 | } 36 | 37 | public extension Examples.Spotify.AlbumScreen.Header { 38 | 39 | @available(*, deprecated, renamed: "init(album:)") 40 | init(info: Examples.Spotify.PreviewInfo) { 41 | self.init(album: info) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/ScrollKit/_Deprecated/View+Deprecated.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) || os(visionOS) 2 | import SwiftUI 3 | 4 | public extension View { 5 | 6 | @available(*, deprecated, renamed: "statusBarHiddenUntilScrolled(offset:)") 7 | func hideStatusBarUntilScrolled( 8 | using offset: Binding 9 | ) -> some View { 10 | self.statusBarHiddenUntilScrolled(offset: offset) 11 | } 12 | 13 | @available(*, deprecated, renamed: "scrollViewOffsetTracking") 14 | func withScrollOffsetTracking( 15 | action: @escaping @MainActor @Sendable (_ offset: CGPoint) -> Void 16 | ) -> some View { 17 | self.scrollViewOffsetTracking(action: action) 18 | } 19 | } 20 | #endif 21 | -------------------------------------------------------------------------------- /Tests/ScrollKitTests/ScrollKitTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import ScrollKit 3 | 4 | final class ScrollKitTests: XCTestCase { 5 | 6 | func testExample() throws { 7 | XCTAssertEqual(1, 1) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script creates a new project version for the current project. 5 | # You can customize this to fit your project when you copy these scripts. 6 | # You can pass in a custom branch if you don't want to use the default one. 7 | 8 | SCRIPT="scripts/package_version.sh" 9 | chmod +x $SCRIPT 10 | bash $SCRIPT 11 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds a for all provided . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Usage: 9 | # build.sh [ default:iOS macOS tvOS watchOS xrOS] 10 | # e.g. `bash scripts/build.sh MyTarget iOS macOS` 11 | 12 | # Exit immediately if a command exits with a non-zero status 13 | set -e 14 | 15 | # Verify that all required arguments are provided 16 | if [ $# -eq 0 ]; then 17 | echo "Error: This script requires at least one argument" 18 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 19 | echo "For instance: $0 MyTarget iOS macOS" 20 | exit 1 21 | fi 22 | 23 | # Define argument variables 24 | TARGET=$1 25 | 26 | # Remove TARGET from arguments list 27 | shift 28 | 29 | # Define platforms variable 30 | if [ $# -eq 0 ]; then 31 | set -- iOS macOS tvOS watchOS xrOS 32 | fi 33 | PLATFORMS=$@ 34 | 35 | # A function that builds $TARGET for a specific platform 36 | build_platform() { 37 | 38 | # Define a local $PLATFORM variable 39 | local PLATFORM=$1 40 | 41 | # Build $TARGET for the $PLATFORM 42 | echo "Building $TARGET for $PLATFORM..." 43 | if ! xcodebuild -scheme $TARGET -derivedDataPath .build -destination generic/platform=$PLATFORM; then 44 | echo "Failed to build $TARGET for $PLATFORM" 45 | return 1 46 | fi 47 | 48 | # Complete successfully 49 | echo "Successfully built $TARGET for $PLATFORM" 50 | } 51 | 52 | # Start script 53 | echo "" 54 | echo "Building $TARGET for [$PLATFORMS]..." 55 | echo "" 56 | 57 | # Loop through all platforms and call the build function 58 | for PLATFORM in $PLATFORMS; do 59 | if ! build_platform "$PLATFORM"; then 60 | exit 1 61 | fi 62 | done 63 | 64 | # Complete successfully 65 | echo "" 66 | echo "Building $TARGET completed successfully!" 67 | echo "" 68 | -------------------------------------------------------------------------------- /scripts/chmod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script makes all scripts in this folder executable. 5 | 6 | # Usage: 7 | # scripts_chmod.sh 8 | # e.g. `bash scripts/chmod.sh` 9 | 10 | # Exit immediately if a command exits with a non-zero status 11 | set -e 12 | 13 | # Use the script folder to refer to other scripts. 14 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 15 | 16 | # Find all .sh files in the FOLDER except chmod.sh 17 | find "$FOLDER" -name "*.sh" ! -name "chmod.sh" -type f | while read -r script; do 18 | chmod +x "$script" 19 | done 20 | -------------------------------------------------------------------------------- /scripts/docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds DocC for a and certain . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | # The documentation ends up in to .build/docs-. 8 | 9 | # Usage: 10 | # docc.sh [ default:iOS macOS tvOS watchOS xrOS] 11 | # e.g. `bash scripts/docc.sh MyTarget iOS macOS` 12 | 13 | # Exit immediately if a command exits with a non-zero status 14 | set -e 15 | 16 | # Fail if any command in a pipeline fails 17 | set -o pipefail 18 | 19 | # Verify that all required arguments are provided 20 | if [ $# -eq 0 ]; then 21 | echo "Error: This script requires at least one argument" 22 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 23 | echo "For instance: $0 MyTarget iOS macOS" 24 | exit 1 25 | fi 26 | 27 | # Define argument variables 28 | TARGET=$1 29 | TARGET_LOWERCASED=$(echo "$1" | tr '[:upper:]' '[:lower:]') 30 | 31 | # Remove TARGET from arguments list 32 | shift 33 | 34 | # Define platforms variable 35 | if [ $# -eq 0 ]; then 36 | set -- iOS macOS tvOS watchOS xrOS 37 | fi 38 | PLATFORMS=$@ 39 | 40 | # Prepare the package for DocC 41 | swift package resolve; 42 | 43 | # A function that builds $TARGET for a specific platform 44 | build_platform() { 45 | 46 | # Define a local $PLATFORM variable and set an exit code 47 | local PLATFORM=$1 48 | local EXIT_CODE=0 49 | 50 | # Define the build folder name, based on the $PLATFORM 51 | case $PLATFORM in 52 | "iOS") 53 | DEBUG_PATH="Debug-iphoneos" 54 | ;; 55 | "macOS") 56 | DEBUG_PATH="Debug" 57 | ;; 58 | "tvOS") 59 | DEBUG_PATH="Debug-appletvos" 60 | ;; 61 | "watchOS") 62 | DEBUG_PATH="Debug-watchos" 63 | ;; 64 | "xrOS") 65 | DEBUG_PATH="Debug-xros" 66 | ;; 67 | *) 68 | echo "Error: Unsupported platform '$PLATFORM'" 69 | exit 1 70 | ;; 71 | esac 72 | 73 | # Build $TARGET docs for the $PLATFORM 74 | echo "Building $TARGET docs for $PLATFORM..." 75 | if ! xcodebuild docbuild -scheme $TARGET -derivedDataPath .build/docbuild -destination "generic/platform=$PLATFORM"; then 76 | echo "Error: Failed to build documentation for $PLATFORM" >&2 77 | return 1 78 | fi 79 | 80 | # Transform docs for static hosting 81 | if ! $(xcrun --find docc) process-archive \ 82 | transform-for-static-hosting .build/docbuild/Build/Products/$DEBUG_PATH/$TARGET.doccarchive \ 83 | --output-path .build/docs-$PLATFORM \ 84 | --hosting-base-path "$TARGET"; then 85 | echo "Error: Failed to transform documentation for $PLATFORM" >&2 86 | return 1 87 | fi 88 | 89 | # Inject a root redirect script on the root page 90 | echo "" > .build/docs-$PLATFORM/index.html; 91 | 92 | # Complete successfully 93 | echo "Successfully built $TARGET docs for $PLATFORM" 94 | return 0 95 | } 96 | 97 | # Start script 98 | echo "" 99 | echo "Building $TARGET docs for [$PLATFORMS]..." 100 | echo "" 101 | 102 | # Loop through all platforms and call the build function 103 | for PLATFORM in $PLATFORMS; do 104 | if ! build_platform "$PLATFORM"; then 105 | exit 1 106 | fi 107 | done 108 | 109 | # Complete successfully 110 | echo "" 111 | echo "Building $TARGET docs completed successfully!" 112 | echo "" 113 | -------------------------------------------------------------------------------- /scripts/framework.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds DocC for a and certain . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Important: 9 | # This script doesn't work on packages, only on .xcproj projects that generate a framework. 10 | 11 | # Usage: 12 | # framework.sh [ default:iOS macOS tvOS watchOS xrOS] 13 | # e.g. `bash scripts/framework.sh MyTarget iOS macOS` 14 | 15 | # Exit immediately if a command exits with a non-zero status 16 | set -e 17 | 18 | # Verify that all required arguments are provided 19 | if [ $# -eq 0 ]; then 20 | echo "Error: This script requires exactly one argument" 21 | echo "Usage: $0 " 22 | exit 1 23 | fi 24 | 25 | # Define argument variables 26 | TARGET=$1 27 | 28 | # Remove TARGET from arguments list 29 | shift 30 | 31 | # Define platforms variable 32 | if [ $# -eq 0 ]; then 33 | set -- iOS macOS tvOS watchOS xrOS 34 | fi 35 | PLATFORMS=$@ 36 | 37 | # Define local variables 38 | BUILD_FOLDER=.build 39 | BUILD_FOLDER_ARCHIVES=.build/framework_archives 40 | BUILD_FILE=$BUILD_FOLDER/$TARGET.xcframework 41 | BUILD_ZIP=$BUILD_FOLDER/$TARGET.zip 42 | 43 | # Start script 44 | echo "" 45 | echo "Building $TARGET XCFramework for [$PLATFORMS]..." 46 | echo "" 47 | 48 | # Delete old builds 49 | echo "Cleaning old builds..." 50 | rm -rf $BUILD_ZIP 51 | rm -rf $BUILD_FILE 52 | rm -rf $BUILD_FOLDER_ARCHIVES 53 | 54 | 55 | # Generate XCArchive files for all platforms 56 | echo "Generating XCArchives..." 57 | 58 | # Initialize the xcframework command 59 | XCFRAMEWORK_CMD="xcodebuild -create-xcframework" 60 | 61 | # Build iOS archives and append to the xcframework command 62 | if [[ " ${PLATFORMS[@]} " =~ " iOS " ]]; then 63 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=iOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-iOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 64 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=iOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-iOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 65 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-iOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 66 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-iOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 67 | fi 68 | 69 | # Build iOS archive and append to the xcframework command 70 | if [[ " ${PLATFORMS[@]} " =~ " macOS " ]]; then 71 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=macOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-macOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 72 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-macOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 73 | fi 74 | 75 | # Build tvOS archives and append to the xcframework command 76 | if [[ " ${PLATFORMS[@]} " =~ " tvOS " ]]; then 77 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=tvOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 78 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=tvOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 79 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 80 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-tvOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 81 | fi 82 | 83 | # Build watchOS archives and append to the xcframework command 84 | if [[ " ${PLATFORMS[@]} " =~ " watchOS " ]]; then 85 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=watchOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 86 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=watchOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 87 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 88 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-watchOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 89 | fi 90 | 91 | # Build xrOS archives and append to the xcframework command 92 | if [[ " ${PLATFORMS[@]} " =~ " xrOS " ]]; then 93 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=xrOS" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 94 | xcodebuild archive -scheme $TARGET -configuration Release -destination "generic/platform=xrOS Simulator" -archivePath $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS-Sim SKIP_INSTALL=NO BUILD_LIBRARY_FOR_DISTRIBUTION=YES 95 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS.xcarchive/Products/Library/Frameworks/$TARGET.framework" 96 | XCFRAMEWORK_CMD+=" -framework $BUILD_FOLDER_ARCHIVES/$TARGET-xrOS-Sim.xcarchive/Products/Library/Frameworks/$TARGET.framework" 97 | fi 98 | 99 | # Genererate XCFramework 100 | echo "Generating XCFramework..." 101 | XCFRAMEWORK_CMD+=" -output $BUILD_FILE" 102 | eval "$XCFRAMEWORK_CMD" 103 | 104 | # Genererate iOS XCFramework zip 105 | echo "Generating XCFramework zip..." 106 | zip -r $BUILD_ZIP $BUILD_FILE 107 | echo "" 108 | echo "***** CHECKSUM *****" 109 | swift package compute-checksum $BUILD_ZIP 110 | echo "********************" 111 | echo "" 112 | 113 | # Complete successfully 114 | echo "" 115 | echo "$TARGET XCFramework created successfully!" 116 | echo "" 117 | -------------------------------------------------------------------------------- /scripts/git_default_branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script echos the default git branch name. 5 | 6 | # Usage: 7 | # git_default_branch.sh 8 | # e.g. `bash scripts/git_default_branch.sh` 9 | 10 | BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@') 11 | echo $BRANCH 12 | -------------------------------------------------------------------------------- /scripts/package_docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script builds DocC documentation for `Package.swift`. 5 | # This script targets iOS by default, but you can pass in custom . 6 | 7 | # Usage: 8 | # package_docc.sh [ default:iOS] 9 | # e.g. `bash scripts/package_docc.sh iOS macOS` 10 | 11 | # Exit immediately if a command exits with non-zero status 12 | set -e 13 | 14 | # Use the script folder to refer to other scripts. 15 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 17 | SCRIPT_DOCC="$FOLDER/docc.sh" 18 | 19 | # Define platforms variable 20 | if [ $# -eq 0 ]; then 21 | set -- iOS 22 | fi 23 | PLATFORMS=$@ 24 | 25 | # Get package name 26 | PACKAGE_NAME=$("$SCRIPT_PACKAGE_NAME") || { echo "Failed to get package name"; exit 1; } 27 | 28 | # Build package documentation 29 | bash $SCRIPT_DOCC $PACKAGE_NAME $PLATFORMS || { echo "DocC script failed"; exit 1; } 30 | -------------------------------------------------------------------------------- /scripts/package_framework.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script generates an XCFramework for `Package.swift`. 5 | # This script targets iOS by default, but you can pass in custom . 6 | 7 | # Usage: 8 | # package_framework.sh [ default:iOS] 9 | # e.g. `bash scripts/package_framework.sh iOS macOS` 10 | 11 | # Exit immediately if a command exits with non-zero status 12 | set -e 13 | 14 | # Use the script folder to refer to other scripts. 15 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 17 | SCRIPT_FRAMEWORK="$FOLDER/framework.sh" 18 | 19 | # Define platforms variable 20 | if [ $# -eq 0 ]; then 21 | set -- iOS 22 | fi 23 | PLATFORMS=$@ 24 | 25 | # Get package name 26 | PACKAGE_NAME=$("$SCRIPT_PACKAGE_NAME") || { echo "Failed to get package name"; exit 1; } 27 | 28 | # Build package framework 29 | bash $SCRIPT_FRAMEWORK $PACKAGE_NAME $PLATFORMS 30 | -------------------------------------------------------------------------------- /scripts/package_name.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script finds the main target name in `Package.swift`. 5 | 6 | # Usage: 7 | # package_name.sh 8 | # e.g. `bash scripts/package_name.sh` 9 | 10 | # Exit immediately if a command exits with non-zero status 11 | set -e 12 | 13 | # Check that a Package.swift file exists 14 | if [ ! -f "Package.swift" ]; then 15 | echo "Error: Package.swift not found in current directory" 16 | exit 1 17 | fi 18 | 19 | # Using grep and sed to extract the package name 20 | # 1. grep finds the line containing "name:" 21 | # 2. sed extracts the text between quotes 22 | package_name=$(grep -m 1 'name:.*"' Package.swift | sed -n 's/.*name:[[:space:]]*"\([^"]*\)".*/\1/p') 23 | 24 | if [ -z "$package_name" ]; then 25 | echo "Error: Could not find package name in Package.swift" 26 | exit 1 27 | else 28 | echo "$package_name" 29 | fi 30 | -------------------------------------------------------------------------------- /scripts/package_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script creates a new version for `Package.swift`. 5 | # You can pass in a to validate any non-main branch. 6 | 7 | # Usage: 8 | # package_version.sh 9 | # e.g. `bash scripts/package_version.sh master` 10 | 11 | # Exit immediately if a command exits with non-zero status 12 | set -e 13 | 14 | # Use the script folder to refer to other scripts. 15 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | SCRIPT_BRANCH_NAME="$FOLDER/git_default_branch.sh" 17 | SCRIPT_PACKAGE_NAME="$FOLDER/package_name.sh" 18 | SCRIPT_VERSION="$FOLDER/version.sh" 19 | 20 | # Get branch name 21 | DEFAULT_BRANCH=$("$SCRIPT_BRANCH_NAME") || { echo "Failed to get branch name"; exit 1; } 22 | BRANCH_NAME=${1:-$DEFAULT_BRANCH} 23 | 24 | # Get package name 25 | PACKAGE_NAME=$("$SCRIPT_PACKAGE_NAME") || { echo "Failed to get package name"; exit 1; } 26 | 27 | # Build package version 28 | bash $SCRIPT_VERSION $PACKAGE_NAME $BRANCH_NAME 29 | -------------------------------------------------------------------------------- /scripts/sync_from.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script syncs Swift Package Scripts from a . 5 | # This script will overwrite the existing "scripts" folder. 6 | # Only pass in the full path to a Swift Package Scripts root. 7 | 8 | # Usage: 9 | # package_name.sh 10 | # e.g. `bash sync_from.sh ../SwiftPackageScripts` 11 | 12 | # Define argument variables 13 | SOURCE=$1 14 | 15 | # Define variables 16 | FOLDER="scripts/" 17 | SOURCE_FOLDER="$SOURCE/$FOLDER" 18 | 19 | # Start script 20 | echo "" 21 | echo "Syncing scripts from $SOURCE_FOLDER..." 22 | echo "" 23 | 24 | # Remove existing folder 25 | rm -rf $FOLDER 26 | 27 | # Copy folder 28 | cp -r "$SOURCE_FOLDER/" "$FOLDER/" 29 | 30 | # Complete successfully 31 | echo "" 32 | echo "Script syncing from $SOURCE_FOLDER completed successfully!" 33 | echo "" 34 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script tests a for all provided . 5 | 6 | # Usage: 7 | # test.sh [ default:iOS macOS tvOS watchOS xrOS] 8 | # e.g. `bash scripts/test.sh MyTarget iOS macOS` 9 | 10 | # Exit immediately if a command exits with a non-zero status 11 | set -e 12 | 13 | # Verify that all required arguments are provided 14 | if [ $# -eq 0 ]; then 15 | echo "Error: This script requires at least one argument" 16 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 17 | echo "For instance: $0 MyTarget iOS macOS" 18 | exit 1 19 | fi 20 | 21 | # Define argument variables 22 | TARGET=$1 23 | 24 | # Remove TARGET from arguments list 25 | shift 26 | 27 | # Define platforms variable 28 | if [ $# -eq 0 ]; then 29 | set -- iOS macOS tvOS watchOS xrOS 30 | fi 31 | PLATFORMS=$@ 32 | 33 | # Start script 34 | echo "" 35 | echo "Testing $TARGET for [$PLATFORMS]..." 36 | echo "" 37 | 38 | # A function that gets the latest simulator for a certain OS. 39 | get_latest_simulator() { 40 | local PLATFORM=$1 41 | local SIMULATOR_TYPE 42 | 43 | case $PLATFORM in 44 | "iOS") 45 | SIMULATOR_TYPE="iPhone" 46 | ;; 47 | "tvOS") 48 | SIMULATOR_TYPE="Apple TV" 49 | ;; 50 | "watchOS") 51 | SIMULATOR_TYPE="Apple Watch" 52 | ;; 53 | "xrOS") 54 | SIMULATOR_TYPE="Apple Vision" 55 | ;; 56 | *) 57 | echo "Error: Unsupported platform for simulator '$PLATFORM'" 58 | return 1 59 | ;; 60 | esac 61 | 62 | # Get the latest simulator for the platform 63 | xcrun simctl list devices available | grep "$SIMULATOR_TYPE" | tail -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/' 64 | } 65 | 66 | # A function that tests $TARGET for a specific platform 67 | test_platform() { 68 | 69 | # Define a local $PLATFORM variable 70 | local PLATFORM="${1//_/ }" 71 | 72 | # Define the destination, based on the $PLATFORM 73 | case $PLATFORM in 74 | "iOS"|"tvOS"|"watchOS"|"xrOS") 75 | local SIMULATOR_UDID=$(get_latest_simulator "$PLATFORM") 76 | if [ -z "$SIMULATOR_UDID" ]; then 77 | echo "Error: No simulator found for $PLATFORM" 78 | return 1 79 | fi 80 | DESTINATION="id=$SIMULATOR_UDID" 81 | ;; 82 | "macOS") 83 | DESTINATION="platform=macOS" 84 | ;; 85 | *) 86 | echo "Error: Unsupported platform '$PLATFORM'" 87 | return 1 88 | ;; 89 | esac 90 | 91 | # Test $TARGET for the $DESTINATION 92 | echo "Testing $TARGET for $PLATFORM..." 93 | xcodebuild test -scheme $TARGET -derivedDataPath .build -destination "$DESTINATION" -enableCodeCoverage YES 94 | local TEST_RESULT=$? 95 | 96 | if [[ $TEST_RESULT -ne 0 ]]; then 97 | return $TEST_RESULT 98 | fi 99 | 100 | # Complete successfully 101 | echo "Successfully tested $TARGET for $PLATFORM" 102 | return 0 103 | } 104 | 105 | # Loop through all platforms and call the test function 106 | for PLATFORM in $PLATFORMS; do 107 | if ! test_platform "$PLATFORM"; then 108 | exit 1 109 | fi 110 | done 111 | 112 | # Complete successfully 113 | echo "" 114 | echo "Testing $TARGET completed successfully!" 115 | echo "" 116 | -------------------------------------------------------------------------------- /scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script creates a new version for the provided and . 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Usage: 9 | # version.sh [ default:iOS macOS tvOS watchOS xrOS]" 10 | # e.g. `scripts/version.sh MyTarget master iOS macOS` 11 | 12 | # This script will: 13 | # * Call version_validate_git.sh to validate the git repo. 14 | # * Call version_validate_target to run tests, swiftlint, etc. 15 | # * Call version_bump.sh if all validation steps above passed. 16 | 17 | # Exit immediately if a command exits with a non-zero status 18 | set -e 19 | 20 | # Verify that all required arguments are provided 21 | if [ $# -lt 2 ]; then 22 | echo "Error: This script requires at least two arguments" 23 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 24 | echo "For instance: $0 MyTarget master iOS macOS" 25 | exit 1 26 | fi 27 | 28 | # Define argument variables 29 | TARGET=$1 30 | BRANCH=${2:-main} 31 | 32 | # Remove TARGET and BRANCH from arguments list 33 | shift 34 | shift 35 | 36 | # Read platform arguments or use default value 37 | if [ $# -eq 0 ]; then 38 | set -- iOS macOS tvOS watchOS xrOS 39 | fi 40 | 41 | # Use the script folder to refer to other scripts. 42 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 43 | SCRIPT_VALIDATE_GIT="$FOLDER/version_validate_git.sh" 44 | SCRIPT_VALIDATE_TARGET="$FOLDER/version_validate_target.sh" 45 | SCRIPT_VERSION_BUMP="$FOLDER/version_bump.sh" 46 | 47 | # A function that run a certain script and checks for errors 48 | run_script() { 49 | local script="$1" 50 | shift # Remove the first argument (the script path) 51 | 52 | if [ ! -f "$script" ]; then 53 | echo "Error: Script not found: $script" 54 | exit 1 55 | fi 56 | 57 | chmod +x "$script" 58 | if ! "$script" "$@"; then 59 | echo "Error: Script $script failed" 60 | exit 1 61 | fi 62 | } 63 | 64 | # Start script 65 | echo "" 66 | echo "Creating a new version for $TARGET on the $BRANCH branch..." 67 | echo "" 68 | 69 | # Validate git and project 70 | echo "Validating..." 71 | run_script "$SCRIPT_VALIDATE_GIT" "$BRANCH" 72 | run_script "$SCRIPT_VALIDATE_TARGET" "$TARGET" 73 | 74 | # Bump version 75 | echo "Bumping version..." 76 | run_script "$SCRIPT_VERSION_BUMP" 77 | 78 | # Complete successfully 79 | echo "" 80 | echo "Version created successfully!" 81 | echo "" 82 | -------------------------------------------------------------------------------- /scripts/version_bump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script bumps the project version number. 5 | # You can append --no-semver to disable semantic version validation. 6 | 7 | # Usage: 8 | # version_bump.sh [--no-semver] 9 | # e.g. `bash scripts/version_bump.sh` 10 | # e.g. `bash scripts/version_bump.sh --no-semver` 11 | 12 | # Exit immediately if a command exits with a non-zero status 13 | set -e 14 | 15 | # Use the script folder to refer to other scripts. 16 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 17 | SCRIPT_VERSION_NUMBER="$FOLDER/version_number.sh" 18 | 19 | 20 | # Parse --no-semver argument 21 | VALIDATE_SEMVER=true 22 | for arg in "$@"; do 23 | case $arg in 24 | --no-semver) 25 | VALIDATE_SEMVER=false 26 | shift # Remove --no-semver from processing 27 | ;; 28 | esac 29 | done 30 | 31 | # Start script 32 | echo "" 33 | echo "Bumping version number..." 34 | echo "" 35 | 36 | # Get the latest version 37 | VERSION=$($SCRIPT_VERSION_NUMBER) 38 | if [ $? -ne 0 ]; then 39 | echo "Failed to get the latest version" 40 | exit 1 41 | fi 42 | 43 | # Print the current version 44 | echo "The current version is: $VERSION" 45 | 46 | # Function to validate semver format, including optional -rc. suffix 47 | validate_semver() { 48 | if [ "$VALIDATE_SEMVER" = false ]; then 49 | return 0 50 | fi 51 | 52 | if [[ $1 =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then 53 | return 0 54 | else 55 | return 1 56 | fi 57 | } 58 | 59 | # Prompt user for new version 60 | while true; do 61 | read -p "Enter the new version number: " NEW_VERSION 62 | 63 | # Validate the version number to ensure that it's a semver version 64 | if validate_semver "$NEW_VERSION"; then 65 | break 66 | else 67 | echo "Invalid version format. Please use semver format (e.g., 1.2.3, v1.2.3, 1.2.3-rc.1, etc.)." 68 | exit 1 69 | fi 70 | done 71 | 72 | # Push the new tag 73 | git push -u origin HEAD 74 | git tag $NEW_VERSION 75 | git push --tags 76 | 77 | # Complete successfully 78 | echo "" 79 | echo "Version tag pushed successfully!" 80 | echo "" 81 | -------------------------------------------------------------------------------- /scripts/version_number.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script returns the latest project version. 5 | 6 | # Usage: 7 | # version_number.sh 8 | # e.g. `bash scripts/version_number.sh` 9 | 10 | # Exit immediately if a command exits with a non-zero status 11 | set -e 12 | 13 | # Check if the current directory is a Git repository 14 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 15 | echo "Error: Not a Git repository" 16 | exit 1 17 | fi 18 | 19 | # Fetch all tags 20 | git fetch --tags > /dev/null 2>&1 21 | 22 | # Get the latest semver tag 23 | latest_version=$(git tag -l --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) 24 | 25 | # Check if we found a version tag 26 | if [ -z "$latest_version" ]; then 27 | echo "Error: No semver tags found in this repository" >&2 28 | exit 1 29 | fi 30 | 31 | # Print the latest version 32 | echo "$latest_version" 33 | -------------------------------------------------------------------------------- /scripts/version_validate_git.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script validates the Git repository for release. 5 | # You can pass in a to validate any non-main branch. 6 | 7 | # Usage: 8 | # version_validate_git.sh " 9 | # e.g. `bash scripts/version_validate_git.sh master` 10 | 11 | # This script will: 12 | # * Validate that the script is run within a git repository. 13 | # * Validate that the git repository doesn't have any uncommitted changes. 14 | # * Validate that the current git branch matches the provided one. 15 | 16 | # Exit immediately if a command exits with a non-zero status 17 | set -e 18 | 19 | # Verify that all required arguments are provided 20 | if [ $# -eq 0 ]; then 21 | echo "Error: This script requires exactly one argument" 22 | echo "Usage: $0 " 23 | exit 1 24 | fi 25 | 26 | # Create local argument variables. 27 | BRANCH=$1 28 | 29 | # Start script 30 | echo "" 31 | echo "Validating git repository..." 32 | echo "" 33 | 34 | # Check if the current directory is a Git repository 35 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 36 | echo "Error: Not a Git repository" 37 | exit 1 38 | fi 39 | 40 | # Check for uncommitted changes 41 | if [ -n "$(git status --porcelain)" ]; then 42 | echo "Error: Git repository is dirty. There are uncommitted changes." 43 | exit 1 44 | fi 45 | 46 | # Verify that we're on the correct branch 47 | current_branch=$(git rev-parse --abbrev-ref HEAD) 48 | if [ "$current_branch" != "$BRANCH" ]; then 49 | echo "Error: Not on the specified branch. Current branch is $current_branch, expected $1." 50 | exit 1 51 | fi 52 | 53 | # The Git repository validation succeeded. 54 | echo "" 55 | echo "Git repository validated successfully!" 56 | echo "" 57 | -------------------------------------------------------------------------------- /scripts/version_validate_target.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Documentation: 4 | # This script validates a for release. 5 | # This script targets iOS, macOS, tvOS, watchOS, and xrOS by default. 6 | # You can pass in a list of if you want to customize the build. 7 | 8 | # Usage: 9 | # version_validate_target.sh [ default:iOS macOS tvOS watchOS xrOS]" 10 | # e.g. `bash scripts/version_validate_target.sh iOS macOS` 11 | 12 | # This script will: 13 | # * Validate that swiftlint passes. 14 | # * Validate that all unit tests passes for all . 15 | 16 | # Exit immediately if a command exits with a non-zero status 17 | set -e 18 | 19 | # Verify that all requires at least one argument" 20 | if [ $# -eq 0 ]; then 21 | echo "Error: This script requires at least one argument" 22 | echo "Usage: $0 [ default:iOS macOS tvOS watchOS xrOS]" 23 | exit 1 24 | fi 25 | 26 | # Create local argument variables. 27 | TARGET=$1 28 | 29 | # Remove TARGET from arguments list 30 | shift 31 | 32 | # Define platforms variable 33 | if [ $# -eq 0 ]; then 34 | set -- iOS macOS tvOS watchOS xrOS 35 | fi 36 | PLATFORMS=$@ 37 | 38 | # Use the script folder to refer to other scripts. 39 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 40 | SCRIPT_TEST="$FOLDER/test.sh" 41 | 42 | # A function that run a certain script and checks for errors 43 | run_script() { 44 | local script="$1" 45 | shift # Remove the first argument (script path) from the argument list 46 | 47 | if [ ! -f "$script" ]; then 48 | echo "Error: Script not found: $script" 49 | exit 1 50 | fi 51 | 52 | chmod +x "$script" 53 | if ! "$script" "$@"; then 54 | echo "Error: Script $script failed" 55 | exit 1 56 | fi 57 | } 58 | 59 | # Start script 60 | echo "" 61 | echo "Validating project..." 62 | echo "" 63 | 64 | # Run SwiftLint 65 | echo "Running SwiftLint" 66 | if ! swiftlint --strict; then 67 | echo "Error: SwiftLint failed" 68 | exit 1 69 | fi 70 | 71 | # Run unit tests 72 | echo "Testing..." 73 | run_script "$SCRIPT_TEST" "$TARGET" "$PLATFORMS" 74 | 75 | # Complete successfully 76 | echo "" 77 | echo "Project successfully validated!" 78 | echo "" 79 | --------------------------------------------------------------------------------