├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── Examples ├── LazyPagerExample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── LazyPagerExampleApp │ ├── AnimatedPagerControlsExample.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── nora1.imageset │ │ │ ├── 356181627_737281678149026_5519646735590788375_n.jpg │ │ │ └── Contents.json │ │ ├── nora2.imageset │ │ │ ├── 356184881_810974757010572_166165563303848404_n.jpg │ │ │ └── Contents.json │ │ ├── nora3.imageset │ │ │ ├── 356184996_1504506290292039_6439519590743317419_n.jpg │ │ │ └── Contents.json │ │ ├── nora4.imageset │ │ │ ├── 356187567_797832131883666_8693445044613773171_n.jpg │ │ │ └── Contents.json │ │ ├── nora5.imageset │ │ │ ├── 356198313_803291668248047_1588179413198578920_n.jpg │ │ │ └── Contents.json │ │ └── nora6.imageset │ │ │ ├── 358743821_933760767702238_5920729387861732707_n.jpg │ │ │ └── Contents.json │ ├── EnvironmentExample.swift │ ├── FullTestView.swift │ ├── LazyPagerExampleApp.swift │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── SimpleExample.swift │ └── VerticalMediaPager.swift ├── LazyPagerExampleAppTests │ └── LazyPagerExampleAppTests.swift └── LazyPagerExampleAppUITests │ ├── ImageScrollViewUITests.swift │ └── ImageScrollViewUITestsLaunchTests.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── LazyPager │ ├── ClearFullScreenBackground.swift │ ├── Collection+Extensions.swift │ ├── LazyPager.swift │ ├── Math.swift │ ├── PagerView.swift │ ├── ViewDataProvider.swift │ └── ZoomableView.swift └── Tests └── LazyPagerTests └── LazyPagerTests.swift /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | A working minimal code example is preferred! 15 | If you cannot produce example code please list the steps to reproduce the behavior: 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Screenshots** 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Examples/LazyPagerExample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 60; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | D33BC0522B69E6EE004B4338 /* SimpleExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33BC0512B69E6EE004B4338 /* SimpleExample.swift */; }; 11 | D33F96FD2C62F582004D934A /* VerticalMediaPager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33F96FC2C62F582004D934A /* VerticalMediaPager.swift */; }; 12 | D353FB592A52174B00C04ABE /* LazyPagerExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB582A52174B00C04ABE /* LazyPagerExampleApp.swift */; }; 13 | D353FB5B2A52174B00C04ABE /* FullTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB5A2A52174B00C04ABE /* FullTestView.swift */; }; 14 | D353FB5D2A52174C00C04ABE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D353FB5C2A52174C00C04ABE /* Assets.xcassets */; }; 15 | D353FB602A52174C00C04ABE /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D353FB5F2A52174C00C04ABE /* Preview Assets.xcassets */; }; 16 | D353FB6A2A52174C00C04ABE /* LazyPagerExampleAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB692A52174C00C04ABE /* LazyPagerExampleAppTests.swift */; }; 17 | D353FB742A52174C00C04ABE /* ImageScrollViewUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB732A52174C00C04ABE /* ImageScrollViewUITests.swift */; }; 18 | D353FB762A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB752A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift */; }; 19 | D367DA132A59E930004497D4 /* LazyPager in Frameworks */ = {isa = PBXBuildFile; productRef = D367DA122A59E930004497D4 /* LazyPager */; }; 20 | D3776B5F2CF5658500AFB89D /* AnimatedPagerControlsExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3776B5E2CF5658500AFB89D /* AnimatedPagerControlsExample.swift */; }; 21 | D38D65E32C62E4B900AA140E /* LazyPager in Frameworks */ = {isa = PBXBuildFile; productRef = D38D65E22C62E4B900AA140E /* LazyPager */; }; 22 | D3B3AEAC2DBD500800AC1E33 /* EnvironmentExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B3AEAB2DBD500500AC1E33 /* EnvironmentExample.swift */; }; 23 | /* End PBXBuildFile section */ 24 | 25 | /* Begin PBXContainerItemProxy section */ 26 | D353FB662A52174C00C04ABE /* PBXContainerItemProxy */ = { 27 | isa = PBXContainerItemProxy; 28 | containerPortal = D353FB4D2A52174B00C04ABE /* Project object */; 29 | proxyType = 1; 30 | remoteGlobalIDString = D353FB542A52174B00C04ABE; 31 | remoteInfo = ImageScrollView; 32 | }; 33 | D353FB702A52174C00C04ABE /* PBXContainerItemProxy */ = { 34 | isa = PBXContainerItemProxy; 35 | containerPortal = D353FB4D2A52174B00C04ABE /* Project object */; 36 | proxyType = 1; 37 | remoteGlobalIDString = D353FB542A52174B00C04ABE; 38 | remoteInfo = ImageScrollView; 39 | }; 40 | /* End PBXContainerItemProxy section */ 41 | 42 | /* Begin PBXFileReference section */ 43 | D33BC0512B69E6EE004B4338 /* SimpleExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleExample.swift; sourceTree = ""; }; 44 | D33F96FC2C62F582004D934A /* VerticalMediaPager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalMediaPager.swift; sourceTree = ""; }; 45 | D353FB552A52174B00C04ABE /* LazyPagerExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LazyPagerExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 46 | D353FB582A52174B00C04ABE /* LazyPagerExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyPagerExampleApp.swift; sourceTree = ""; }; 47 | D353FB5A2A52174B00C04ABE /* FullTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTestView.swift; sourceTree = ""; }; 48 | D353FB5C2A52174C00C04ABE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 49 | D353FB5F2A52174C00C04ABE /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 50 | D353FB652A52174C00C04ABE /* LazyPagerExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LazyPagerExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 51 | D353FB692A52174C00C04ABE /* LazyPagerExampleAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyPagerExampleAppTests.swift; sourceTree = ""; }; 52 | D353FB6F2A52174C00C04ABE /* LazyPagerExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LazyPagerExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 53 | D353FB732A52174C00C04ABE /* ImageScrollViewUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScrollViewUITests.swift; sourceTree = ""; }; 54 | D353FB752A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScrollViewUITestsLaunchTests.swift; sourceTree = ""; }; 55 | D3776B5E2CF5658500AFB89D /* AnimatedPagerControlsExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedPagerControlsExample.swift; sourceTree = ""; }; 56 | D38D65E02C62E47C00AA140E /* LazyPager */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = LazyPager; path = ..; sourceTree = ""; }; 57 | D3B3AEAB2DBD500500AC1E33 /* EnvironmentExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentExample.swift; sourceTree = ""; }; 58 | /* End PBXFileReference section */ 59 | 60 | /* Begin PBXFrameworksBuildPhase section */ 61 | D353FB522A52174B00C04ABE /* Frameworks */ = { 62 | isa = PBXFrameworksBuildPhase; 63 | buildActionMask = 2147483647; 64 | files = ( 65 | D38D65E32C62E4B900AA140E /* LazyPager in Frameworks */, 66 | D367DA132A59E930004497D4 /* LazyPager in Frameworks */, 67 | ); 68 | runOnlyForDeploymentPostprocessing = 0; 69 | }; 70 | D353FB622A52174C00C04ABE /* Frameworks */ = { 71 | isa = PBXFrameworksBuildPhase; 72 | buildActionMask = 2147483647; 73 | files = ( 74 | ); 75 | runOnlyForDeploymentPostprocessing = 0; 76 | }; 77 | D353FB6C2A52174C00C04ABE /* Frameworks */ = { 78 | isa = PBXFrameworksBuildPhase; 79 | buildActionMask = 2147483647; 80 | files = ( 81 | ); 82 | runOnlyForDeploymentPostprocessing = 0; 83 | }; 84 | /* End PBXFrameworksBuildPhase section */ 85 | 86 | /* Begin PBXGroup section */ 87 | D353FB4C2A52174B00C04ABE = { 88 | isa = PBXGroup; 89 | children = ( 90 | D353FB572A52174B00C04ABE /* LazyPagerExampleApp */, 91 | D353FB682A52174C00C04ABE /* LazyPagerExampleAppTests */, 92 | D353FB722A52174C00C04ABE /* LazyPagerExampleAppUITests */, 93 | D353FB562A52174B00C04ABE /* Products */, 94 | D367DA112A59E930004497D4 /* Frameworks */, 95 | ); 96 | sourceTree = ""; 97 | }; 98 | D353FB562A52174B00C04ABE /* Products */ = { 99 | isa = PBXGroup; 100 | children = ( 101 | D353FB552A52174B00C04ABE /* LazyPagerExample.app */, 102 | D353FB652A52174C00C04ABE /* LazyPagerExampleTests.xctest */, 103 | D353FB6F2A52174C00C04ABE /* LazyPagerExampleUITests.xctest */, 104 | ); 105 | name = Products; 106 | sourceTree = ""; 107 | }; 108 | D353FB572A52174B00C04ABE /* LazyPagerExampleApp */ = { 109 | isa = PBXGroup; 110 | children = ( 111 | D3B3AEAB2DBD500500AC1E33 /* EnvironmentExample.swift */, 112 | D353FB582A52174B00C04ABE /* LazyPagerExampleApp.swift */, 113 | D33F96FC2C62F582004D934A /* VerticalMediaPager.swift */, 114 | D33BC0512B69E6EE004B4338 /* SimpleExample.swift */, 115 | D3776B5E2CF5658500AFB89D /* AnimatedPagerControlsExample.swift */, 116 | D353FB5A2A52174B00C04ABE /* FullTestView.swift */, 117 | D353FB5C2A52174C00C04ABE /* Assets.xcassets */, 118 | D353FB5E2A52174C00C04ABE /* Preview Content */, 119 | ); 120 | path = LazyPagerExampleApp; 121 | sourceTree = ""; 122 | }; 123 | D353FB5E2A52174C00C04ABE /* Preview Content */ = { 124 | isa = PBXGroup; 125 | children = ( 126 | D353FB5F2A52174C00C04ABE /* Preview Assets.xcassets */, 127 | ); 128 | path = "Preview Content"; 129 | sourceTree = ""; 130 | }; 131 | D353FB682A52174C00C04ABE /* LazyPagerExampleAppTests */ = { 132 | isa = PBXGroup; 133 | children = ( 134 | D353FB692A52174C00C04ABE /* LazyPagerExampleAppTests.swift */, 135 | ); 136 | path = LazyPagerExampleAppTests; 137 | sourceTree = ""; 138 | }; 139 | D353FB722A52174C00C04ABE /* LazyPagerExampleAppUITests */ = { 140 | isa = PBXGroup; 141 | children = ( 142 | D353FB732A52174C00C04ABE /* ImageScrollViewUITests.swift */, 143 | D353FB752A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift */, 144 | ); 145 | path = LazyPagerExampleAppUITests; 146 | sourceTree = ""; 147 | }; 148 | D367DA112A59E930004497D4 /* Frameworks */ = { 149 | isa = PBXGroup; 150 | children = ( 151 | D38D65E02C62E47C00AA140E /* LazyPager */, 152 | ); 153 | name = Frameworks; 154 | sourceTree = ""; 155 | }; 156 | /* End PBXGroup section */ 157 | 158 | /* Begin PBXNativeTarget section */ 159 | D353FB542A52174B00C04ABE /* LazyPagerExample */ = { 160 | isa = PBXNativeTarget; 161 | buildConfigurationList = D353FB792A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExample" */; 162 | buildPhases = ( 163 | D353FB512A52174B00C04ABE /* Sources */, 164 | D353FB522A52174B00C04ABE /* Frameworks */, 165 | D353FB532A52174B00C04ABE /* Resources */, 166 | ); 167 | buildRules = ( 168 | ); 169 | dependencies = ( 170 | ); 171 | name = LazyPagerExample; 172 | packageProductDependencies = ( 173 | D367DA122A59E930004497D4 /* LazyPager */, 174 | D38D65E22C62E4B900AA140E /* LazyPager */, 175 | ); 176 | productName = ImageScrollView; 177 | productReference = D353FB552A52174B00C04ABE /* LazyPagerExample.app */; 178 | productType = "com.apple.product-type.application"; 179 | }; 180 | D353FB642A52174C00C04ABE /* LazyPagerExampleTests */ = { 181 | isa = PBXNativeTarget; 182 | buildConfigurationList = D353FB7C2A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExampleTests" */; 183 | buildPhases = ( 184 | D353FB612A52174C00C04ABE /* Sources */, 185 | D353FB622A52174C00C04ABE /* Frameworks */, 186 | D353FB632A52174C00C04ABE /* Resources */, 187 | ); 188 | buildRules = ( 189 | ); 190 | dependencies = ( 191 | D353FB672A52174C00C04ABE /* PBXTargetDependency */, 192 | ); 193 | name = LazyPagerExampleTests; 194 | productName = ImageScrollViewTests; 195 | productReference = D353FB652A52174C00C04ABE /* LazyPagerExampleTests.xctest */; 196 | productType = "com.apple.product-type.bundle.unit-test"; 197 | }; 198 | D353FB6E2A52174C00C04ABE /* LazyPagerExampleUITests */ = { 199 | isa = PBXNativeTarget; 200 | buildConfigurationList = D353FB7F2A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExampleUITests" */; 201 | buildPhases = ( 202 | D353FB6B2A52174C00C04ABE /* Sources */, 203 | D353FB6C2A52174C00C04ABE /* Frameworks */, 204 | D353FB6D2A52174C00C04ABE /* Resources */, 205 | ); 206 | buildRules = ( 207 | ); 208 | dependencies = ( 209 | D353FB712A52174C00C04ABE /* PBXTargetDependency */, 210 | ); 211 | name = LazyPagerExampleUITests; 212 | productName = ImageScrollViewUITests; 213 | productReference = D353FB6F2A52174C00C04ABE /* LazyPagerExampleUITests.xctest */; 214 | productType = "com.apple.product-type.bundle.ui-testing"; 215 | }; 216 | /* End PBXNativeTarget section */ 217 | 218 | /* Begin PBXProject section */ 219 | D353FB4D2A52174B00C04ABE /* Project object */ = { 220 | isa = PBXProject; 221 | attributes = { 222 | BuildIndependentTargetsInParallel = 1; 223 | LastSwiftUpdateCheck = 1420; 224 | LastUpgradeCheck = 1420; 225 | TargetAttributes = { 226 | D353FB542A52174B00C04ABE = { 227 | CreatedOnToolsVersion = 14.2; 228 | }; 229 | D353FB642A52174C00C04ABE = { 230 | CreatedOnToolsVersion = 14.2; 231 | TestTargetID = D353FB542A52174B00C04ABE; 232 | }; 233 | D353FB6E2A52174C00C04ABE = { 234 | CreatedOnToolsVersion = 14.2; 235 | TestTargetID = D353FB542A52174B00C04ABE; 236 | }; 237 | }; 238 | }; 239 | buildConfigurationList = D353FB502A52174B00C04ABE /* Build configuration list for PBXProject "LazyPagerExample" */; 240 | compatibilityVersion = "Xcode 14.0"; 241 | developmentRegion = en; 242 | hasScannedForEncodings = 0; 243 | knownRegions = ( 244 | en, 245 | Base, 246 | ); 247 | mainGroup = D353FB4C2A52174B00C04ABE; 248 | packageReferences = ( 249 | D38D65E12C62E4B900AA140E /* XCLocalSwiftPackageReference "../" */, 250 | ); 251 | productRefGroup = D353FB562A52174B00C04ABE /* Products */; 252 | projectDirPath = ""; 253 | projectRoot = ""; 254 | targets = ( 255 | D353FB542A52174B00C04ABE /* LazyPagerExample */, 256 | D353FB642A52174C00C04ABE /* LazyPagerExampleTests */, 257 | D353FB6E2A52174C00C04ABE /* LazyPagerExampleUITests */, 258 | ); 259 | }; 260 | /* End PBXProject section */ 261 | 262 | /* Begin PBXResourcesBuildPhase section */ 263 | D353FB532A52174B00C04ABE /* Resources */ = { 264 | isa = PBXResourcesBuildPhase; 265 | buildActionMask = 2147483647; 266 | files = ( 267 | D353FB602A52174C00C04ABE /* Preview Assets.xcassets in Resources */, 268 | D353FB5D2A52174C00C04ABE /* Assets.xcassets in Resources */, 269 | ); 270 | runOnlyForDeploymentPostprocessing = 0; 271 | }; 272 | D353FB632A52174C00C04ABE /* Resources */ = { 273 | isa = PBXResourcesBuildPhase; 274 | buildActionMask = 2147483647; 275 | files = ( 276 | ); 277 | runOnlyForDeploymentPostprocessing = 0; 278 | }; 279 | D353FB6D2A52174C00C04ABE /* Resources */ = { 280 | isa = PBXResourcesBuildPhase; 281 | buildActionMask = 2147483647; 282 | files = ( 283 | ); 284 | runOnlyForDeploymentPostprocessing = 0; 285 | }; 286 | /* End PBXResourcesBuildPhase section */ 287 | 288 | /* Begin PBXSourcesBuildPhase section */ 289 | D353FB512A52174B00C04ABE /* Sources */ = { 290 | isa = PBXSourcesBuildPhase; 291 | buildActionMask = 2147483647; 292 | files = ( 293 | D353FB5B2A52174B00C04ABE /* FullTestView.swift in Sources */, 294 | D33F96FD2C62F582004D934A /* VerticalMediaPager.swift in Sources */, 295 | D3776B5F2CF5658500AFB89D /* AnimatedPagerControlsExample.swift in Sources */, 296 | D33BC0522B69E6EE004B4338 /* SimpleExample.swift in Sources */, 297 | D3B3AEAC2DBD500800AC1E33 /* EnvironmentExample.swift in Sources */, 298 | D353FB592A52174B00C04ABE /* LazyPagerExampleApp.swift in Sources */, 299 | ); 300 | runOnlyForDeploymentPostprocessing = 0; 301 | }; 302 | D353FB612A52174C00C04ABE /* Sources */ = { 303 | isa = PBXSourcesBuildPhase; 304 | buildActionMask = 2147483647; 305 | files = ( 306 | D353FB6A2A52174C00C04ABE /* LazyPagerExampleAppTests.swift in Sources */, 307 | ); 308 | runOnlyForDeploymentPostprocessing = 0; 309 | }; 310 | D353FB6B2A52174C00C04ABE /* Sources */ = { 311 | isa = PBXSourcesBuildPhase; 312 | buildActionMask = 2147483647; 313 | files = ( 314 | D353FB762A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift in Sources */, 315 | D353FB742A52174C00C04ABE /* ImageScrollViewUITests.swift in Sources */, 316 | ); 317 | runOnlyForDeploymentPostprocessing = 0; 318 | }; 319 | /* End PBXSourcesBuildPhase section */ 320 | 321 | /* Begin PBXTargetDependency section */ 322 | D353FB672A52174C00C04ABE /* PBXTargetDependency */ = { 323 | isa = PBXTargetDependency; 324 | target = D353FB542A52174B00C04ABE /* LazyPagerExample */; 325 | targetProxy = D353FB662A52174C00C04ABE /* PBXContainerItemProxy */; 326 | }; 327 | D353FB712A52174C00C04ABE /* PBXTargetDependency */ = { 328 | isa = PBXTargetDependency; 329 | target = D353FB542A52174B00C04ABE /* LazyPagerExample */; 330 | targetProxy = D353FB702A52174C00C04ABE /* PBXContainerItemProxy */; 331 | }; 332 | /* End PBXTargetDependency section */ 333 | 334 | /* Begin XCBuildConfiguration section */ 335 | D353FB772A52174C00C04ABE /* Debug */ = { 336 | isa = XCBuildConfiguration; 337 | buildSettings = { 338 | ALWAYS_SEARCH_USER_PATHS = NO; 339 | CLANG_ANALYZER_NONNULL = YES; 340 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 341 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 342 | CLANG_ENABLE_MODULES = YES; 343 | CLANG_ENABLE_OBJC_ARC = YES; 344 | CLANG_ENABLE_OBJC_WEAK = YES; 345 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 346 | CLANG_WARN_BOOL_CONVERSION = YES; 347 | CLANG_WARN_COMMA = YES; 348 | CLANG_WARN_CONSTANT_CONVERSION = YES; 349 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 350 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 351 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 352 | CLANG_WARN_EMPTY_BODY = YES; 353 | CLANG_WARN_ENUM_CONVERSION = YES; 354 | CLANG_WARN_INFINITE_RECURSION = YES; 355 | CLANG_WARN_INT_CONVERSION = YES; 356 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 357 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 358 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 359 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 360 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 361 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 362 | CLANG_WARN_STRICT_PROTOTYPES = YES; 363 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 364 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 365 | CLANG_WARN_UNREACHABLE_CODE = YES; 366 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 367 | COPY_PHASE_STRIP = NO; 368 | DEBUG_INFORMATION_FORMAT = dwarf; 369 | ENABLE_STRICT_OBJC_MSGSEND = YES; 370 | ENABLE_TESTABILITY = YES; 371 | GCC_C_LANGUAGE_STANDARD = gnu11; 372 | GCC_DYNAMIC_NO_PIC = NO; 373 | GCC_NO_COMMON_BLOCKS = YES; 374 | GCC_OPTIMIZATION_LEVEL = 0; 375 | GCC_PREPROCESSOR_DEFINITIONS = ( 376 | "DEBUG=1", 377 | "$(inherited)", 378 | ); 379 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 380 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 381 | GCC_WARN_UNDECLARED_SELECTOR = YES; 382 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 383 | GCC_WARN_UNUSED_FUNCTION = YES; 384 | GCC_WARN_UNUSED_VARIABLE = YES; 385 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 386 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 387 | MTL_FAST_MATH = YES; 388 | ONLY_ACTIVE_ARCH = YES; 389 | SDKROOT = iphoneos; 390 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 391 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 392 | }; 393 | name = Debug; 394 | }; 395 | D353FB782A52174C00C04ABE /* Release */ = { 396 | isa = XCBuildConfiguration; 397 | buildSettings = { 398 | ALWAYS_SEARCH_USER_PATHS = NO; 399 | CLANG_ANALYZER_NONNULL = YES; 400 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 401 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 402 | CLANG_ENABLE_MODULES = YES; 403 | CLANG_ENABLE_OBJC_ARC = YES; 404 | CLANG_ENABLE_OBJC_WEAK = YES; 405 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 406 | CLANG_WARN_BOOL_CONVERSION = YES; 407 | CLANG_WARN_COMMA = YES; 408 | CLANG_WARN_CONSTANT_CONVERSION = YES; 409 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 410 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 411 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 412 | CLANG_WARN_EMPTY_BODY = YES; 413 | CLANG_WARN_ENUM_CONVERSION = YES; 414 | CLANG_WARN_INFINITE_RECURSION = YES; 415 | CLANG_WARN_INT_CONVERSION = YES; 416 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 417 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 418 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 419 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 420 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 421 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 422 | CLANG_WARN_STRICT_PROTOTYPES = YES; 423 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 424 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 425 | CLANG_WARN_UNREACHABLE_CODE = YES; 426 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 427 | COPY_PHASE_STRIP = NO; 428 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 429 | ENABLE_NS_ASSERTIONS = NO; 430 | ENABLE_STRICT_OBJC_MSGSEND = YES; 431 | GCC_C_LANGUAGE_STANDARD = gnu11; 432 | GCC_NO_COMMON_BLOCKS = YES; 433 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 434 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 435 | GCC_WARN_UNDECLARED_SELECTOR = YES; 436 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 437 | GCC_WARN_UNUSED_FUNCTION = YES; 438 | GCC_WARN_UNUSED_VARIABLE = YES; 439 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 440 | MTL_ENABLE_DEBUG_INFO = NO; 441 | MTL_FAST_MATH = YES; 442 | SDKROOT = iphoneos; 443 | SWIFT_COMPILATION_MODE = wholemodule; 444 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 445 | VALIDATE_PRODUCT = YES; 446 | }; 447 | name = Release; 448 | }; 449 | D353FB7A2A52174C00C04ABE /* Debug */ = { 450 | isa = XCBuildConfiguration; 451 | buildSettings = { 452 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 453 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 454 | CODE_SIGN_STYLE = Automatic; 455 | CURRENT_PROJECT_VERSION = 1; 456 | DEVELOPMENT_ASSET_PATHS = "\"LazyPagerExampleApp/Preview Content\""; 457 | DEVELOPMENT_TEAM = BX46265734; 458 | ENABLE_PREVIEWS = YES; 459 | GENERATE_INFOPLIST_FILE = YES; 460 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 461 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 462 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 463 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 464 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 465 | LD_RUNPATH_SEARCH_PATHS = ( 466 | "$(inherited)", 467 | "@executable_path/Frameworks", 468 | ); 469 | MARKETING_VERSION = 1.0; 470 | PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollView; 471 | PRODUCT_NAME = "$(TARGET_NAME)"; 472 | SWIFT_EMIT_LOC_STRINGS = YES; 473 | SWIFT_VERSION = 5.0; 474 | TARGETED_DEVICE_FAMILY = "1,2"; 475 | }; 476 | name = Debug; 477 | }; 478 | D353FB7B2A52174C00C04ABE /* Release */ = { 479 | isa = XCBuildConfiguration; 480 | buildSettings = { 481 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 482 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 483 | CODE_SIGN_STYLE = Automatic; 484 | CURRENT_PROJECT_VERSION = 1; 485 | DEVELOPMENT_ASSET_PATHS = "\"LazyPagerExampleApp/Preview Content\""; 486 | DEVELOPMENT_TEAM = BX46265734; 487 | ENABLE_PREVIEWS = YES; 488 | GENERATE_INFOPLIST_FILE = YES; 489 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 490 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 491 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 492 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 493 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 494 | LD_RUNPATH_SEARCH_PATHS = ( 495 | "$(inherited)", 496 | "@executable_path/Frameworks", 497 | ); 498 | MARKETING_VERSION = 1.0; 499 | PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollView; 500 | PRODUCT_NAME = "$(TARGET_NAME)"; 501 | SWIFT_EMIT_LOC_STRINGS = YES; 502 | SWIFT_VERSION = 5.0; 503 | TARGETED_DEVICE_FAMILY = "1,2"; 504 | }; 505 | name = Release; 506 | }; 507 | D353FB7D2A52174C00C04ABE /* Debug */ = { 508 | isa = XCBuildConfiguration; 509 | buildSettings = { 510 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 511 | BUNDLE_LOADER = "$(TEST_HOST)"; 512 | CODE_SIGN_STYLE = Automatic; 513 | CURRENT_PROJECT_VERSION = 1; 514 | DEVELOPMENT_TEAM = BX46265734; 515 | GENERATE_INFOPLIST_FILE = YES; 516 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 517 | MARKETING_VERSION = 1.0; 518 | PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollViewTests; 519 | PRODUCT_NAME = "$(TARGET_NAME)"; 520 | SWIFT_EMIT_LOC_STRINGS = NO; 521 | SWIFT_VERSION = 5.0; 522 | TARGETED_DEVICE_FAMILY = "1,2"; 523 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LazyPagerExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/LazyPagerExample"; 524 | }; 525 | name = Debug; 526 | }; 527 | D353FB7E2A52174C00C04ABE /* Release */ = { 528 | isa = XCBuildConfiguration; 529 | buildSettings = { 530 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 531 | BUNDLE_LOADER = "$(TEST_HOST)"; 532 | CODE_SIGN_STYLE = Automatic; 533 | CURRENT_PROJECT_VERSION = 1; 534 | DEVELOPMENT_TEAM = BX46265734; 535 | GENERATE_INFOPLIST_FILE = YES; 536 | IPHONEOS_DEPLOYMENT_TARGET = 16.2; 537 | MARKETING_VERSION = 1.0; 538 | PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollViewTests; 539 | PRODUCT_NAME = "$(TARGET_NAME)"; 540 | SWIFT_EMIT_LOC_STRINGS = NO; 541 | SWIFT_VERSION = 5.0; 542 | TARGETED_DEVICE_FAMILY = "1,2"; 543 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LazyPagerExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/LazyPagerExample"; 544 | }; 545 | name = Release; 546 | }; 547 | D353FB802A52174C00C04ABE /* Debug */ = { 548 | isa = XCBuildConfiguration; 549 | buildSettings = { 550 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 551 | CODE_SIGN_STYLE = Automatic; 552 | CURRENT_PROJECT_VERSION = 1; 553 | DEVELOPMENT_TEAM = BX46265734; 554 | GENERATE_INFOPLIST_FILE = YES; 555 | MARKETING_VERSION = 1.0; 556 | PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollViewUITests; 557 | PRODUCT_NAME = "$(TARGET_NAME)"; 558 | SWIFT_EMIT_LOC_STRINGS = NO; 559 | SWIFT_VERSION = 5.0; 560 | TARGETED_DEVICE_FAMILY = "1,2"; 561 | TEST_TARGET_NAME = ImageScrollView; 562 | }; 563 | name = Debug; 564 | }; 565 | D353FB812A52174C00C04ABE /* Release */ = { 566 | isa = XCBuildConfiguration; 567 | buildSettings = { 568 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 569 | CODE_SIGN_STYLE = Automatic; 570 | CURRENT_PROJECT_VERSION = 1; 571 | DEVELOPMENT_TEAM = BX46265734; 572 | GENERATE_INFOPLIST_FILE = YES; 573 | MARKETING_VERSION = 1.0; 574 | PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollViewUITests; 575 | PRODUCT_NAME = "$(TARGET_NAME)"; 576 | SWIFT_EMIT_LOC_STRINGS = NO; 577 | SWIFT_VERSION = 5.0; 578 | TARGETED_DEVICE_FAMILY = "1,2"; 579 | TEST_TARGET_NAME = ImageScrollView; 580 | }; 581 | name = Release; 582 | }; 583 | /* End XCBuildConfiguration section */ 584 | 585 | /* Begin XCConfigurationList section */ 586 | D353FB502A52174B00C04ABE /* Build configuration list for PBXProject "LazyPagerExample" */ = { 587 | isa = XCConfigurationList; 588 | buildConfigurations = ( 589 | D353FB772A52174C00C04ABE /* Debug */, 590 | D353FB782A52174C00C04ABE /* Release */, 591 | ); 592 | defaultConfigurationIsVisible = 0; 593 | defaultConfigurationName = Release; 594 | }; 595 | D353FB792A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExample" */ = { 596 | isa = XCConfigurationList; 597 | buildConfigurations = ( 598 | D353FB7A2A52174C00C04ABE /* Debug */, 599 | D353FB7B2A52174C00C04ABE /* Release */, 600 | ); 601 | defaultConfigurationIsVisible = 0; 602 | defaultConfigurationName = Release; 603 | }; 604 | D353FB7C2A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExampleTests" */ = { 605 | isa = XCConfigurationList; 606 | buildConfigurations = ( 607 | D353FB7D2A52174C00C04ABE /* Debug */, 608 | D353FB7E2A52174C00C04ABE /* Release */, 609 | ); 610 | defaultConfigurationIsVisible = 0; 611 | defaultConfigurationName = Release; 612 | }; 613 | D353FB7F2A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExampleUITests" */ = { 614 | isa = XCConfigurationList; 615 | buildConfigurations = ( 616 | D353FB802A52174C00C04ABE /* Debug */, 617 | D353FB812A52174C00C04ABE /* Release */, 618 | ); 619 | defaultConfigurationIsVisible = 0; 620 | defaultConfigurationName = Release; 621 | }; 622 | /* End XCConfigurationList section */ 623 | 624 | /* Begin XCLocalSwiftPackageReference section */ 625 | D38D65E12C62E4B900AA140E /* XCLocalSwiftPackageReference "../" */ = { 626 | isa = XCLocalSwiftPackageReference; 627 | relativePath = ../; 628 | }; 629 | /* End XCLocalSwiftPackageReference section */ 630 | 631 | /* Begin XCSwiftPackageProductDependency section */ 632 | D367DA122A59E930004497D4 /* LazyPager */ = { 633 | isa = XCSwiftPackageProductDependency; 634 | productName = LazyPager; 635 | }; 636 | D38D65E22C62E4B900AA140E /* LazyPager */ = { 637 | isa = XCSwiftPackageProductDependency; 638 | productName = LazyPager; 639 | }; 640 | /* End XCSwiftPackageProductDependency section */ 641 | }; 642 | rootObject = D353FB4D2A52174B00C04ABE /* Project object */; 643 | } 644 | -------------------------------------------------------------------------------- /Examples/LazyPagerExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/LazyPagerExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/AnimatedPagerControlsExample.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import LazyPager 3 | 4 | 5 | struct AnimatedPagerControlsExample: View { 6 | 7 | @State var data = [ 8 | "nora1", 9 | "nora2", 10 | "nora3", 11 | "nora4", 12 | "nora5", 13 | "nora6", 14 | ] 15 | 16 | @State var show = false 17 | @State var index = 0 18 | 19 | var body: some View { 20 | VStack { 21 | LazyPager(data: data, page: $index) { element in 22 | Image(element) 23 | .resizable() 24 | .aspectRatio(contentMode: .fit) 25 | } 26 | HStack(spacing: 20) { 27 | Button("First") { 28 | withAnimation { 29 | index = 0 30 | } 31 | } 32 | Button("Prev") { 33 | withAnimation { 34 | if index > 0 { 35 | index -= 1 36 | } 37 | } 38 | 39 | } 40 | Button("Next") { 41 | withAnimation { 42 | if index < data.count { 43 | index += 1 44 | } 45 | } 46 | 47 | } 48 | Button("Last") { 49 | withAnimation { 50 | index = data.count - 1 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | struct AnimatedPagerControlsExample_Previews: PreviewProvider { 59 | static var previews: some View { 60 | AnimatedPagerControlsExample() 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/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 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora1.imageset/356181627_737281678149026_5519646735590788375_n.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gh123man/SwiftUI-LazyPager/77588f4e1e303dc50db8279064c6d875ccb59044/Examples/LazyPagerExampleApp/Assets.xcassets/nora1.imageset/356181627_737281678149026_5519646735590788375_n.jpg -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "356181627_737281678149026_5519646735590788375_n.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora2.imageset/356184881_810974757010572_166165563303848404_n.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gh123man/SwiftUI-LazyPager/77588f4e1e303dc50db8279064c6d875ccb59044/Examples/LazyPagerExampleApp/Assets.xcassets/nora2.imageset/356184881_810974757010572_166165563303848404_n.jpg -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "356184881_810974757010572_166165563303848404_n.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora3.imageset/356184996_1504506290292039_6439519590743317419_n.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gh123man/SwiftUI-LazyPager/77588f4e1e303dc50db8279064c6d875ccb59044/Examples/LazyPagerExampleApp/Assets.xcassets/nora3.imageset/356184996_1504506290292039_6439519590743317419_n.jpg -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "356184996_1504506290292039_6439519590743317419_n.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora4.imageset/356187567_797832131883666_8693445044613773171_n.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gh123man/SwiftUI-LazyPager/77588f4e1e303dc50db8279064c6d875ccb59044/Examples/LazyPagerExampleApp/Assets.xcassets/nora4.imageset/356187567_797832131883666_8693445044613773171_n.jpg -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora4.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "356187567_797832131883666_8693445044613773171_n.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora5.imageset/356198313_803291668248047_1588179413198578920_n.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gh123man/SwiftUI-LazyPager/77588f4e1e303dc50db8279064c6d875ccb59044/Examples/LazyPagerExampleApp/Assets.xcassets/nora5.imageset/356198313_803291668248047_1588179413198578920_n.jpg -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora5.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "356198313_803291668248047_1588179413198578920_n.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora6.imageset/358743821_933760767702238_5920729387861732707_n.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gh123man/SwiftUI-LazyPager/77588f4e1e303dc50db8279064c6d875ccb59044/Examples/LazyPagerExampleApp/Assets.xcassets/nora6.imageset/358743821_933760767702238_5920729387861732707_n.jpg -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Assets.xcassets/nora6.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "358743821_933760767702238_5920729387861732707_n.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/EnvironmentExample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentExample.swift 3 | // LazyPagerExample 4 | // 5 | // Created by Brian Floersch on 4/26/25. 6 | // 7 | 8 | import SwiftUI 9 | import LazyPager 10 | 11 | struct SubView: View { 12 | 13 | @EnvironmentObject var textHolder: TextHolder 14 | @Environment(\.customValue) var customValue 15 | 16 | var parentText: String 17 | var body: some View { 18 | VStack { 19 | Text("\(textHolder.str) \(parentText)") 20 | .font(.title) 21 | .padding() 22 | Text("Environment value: \(customValue)") 23 | .font(.subheadline) 24 | .padding() 25 | } 26 | } 27 | } 28 | 29 | struct EnvironmentExample: View { 30 | 31 | @State var data = [ 32 | "nora1", 33 | "nora2", 34 | "nora3", 35 | "nora4", 36 | "nora5", 37 | "nora6", 38 | ] 39 | 40 | @State var show = false 41 | 42 | var body: some View { 43 | ZStack { 44 | LazyPager(data: data) { element in 45 | SubView(parentText: element) 46 | } 47 | } 48 | } 49 | } 50 | 51 | class TextHolder: ObservableObject { 52 | let str: String 53 | 54 | init(str: String) { 55 | self.str = str 56 | } 57 | } 58 | 59 | private struct CustomEnvironmentKey: EnvironmentKey { 60 | static let defaultValue: String = "default value" 61 | } 62 | 63 | extension EnvironmentValues { 64 | var customValue: String { 65 | get { self[CustomEnvironmentKey.self] } 66 | set { self[CustomEnvironmentKey.self] = newValue } 67 | } 68 | } 69 | 70 | #Preview { 71 | EnvironmentExample() 72 | .environmentObject(TextHolder(str: "hello world")) 73 | .environment(\.customValue, "custom environment value") 74 | } 75 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/FullTestView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // LazyPager 4 | // 5 | // Created by Brian Floersch on 7/2/23. 6 | // 7 | 8 | import SwiftUI 9 | import LazyPager 10 | 11 | 12 | struct Foo { 13 | let id = UUID() 14 | var img: String 15 | let idx: Int 16 | } 17 | 18 | struct FullTestView: View { 19 | 20 | var direction: Direction 21 | @State var data = [ 22 | Foo(img: "nora1", idx: 0), 23 | Foo(img: "nora2", idx: 1), 24 | Foo(img: "nora3", idx: 2), 25 | Foo(img: "nora4", idx: 3), 26 | Foo(img: "nora5", idx: 4), 27 | Foo(img: "nora6", idx: 5), 28 | Foo(img: "nora1", idx: 6), 29 | Foo(img: "nora2", idx: 7), 30 | Foo(img: "nora3", idx: 8), 31 | Foo(img: "nora4", idx: 9), 32 | Foo(img: "nora5", idx: 10), 33 | Foo(img: "nora6", idx: 11), 34 | ] 35 | 36 | @Binding var show: Bool 37 | @State var opacity: CGFloat = 1 38 | @State var index = 0 39 | @State var loadPager = false 40 | 41 | var body: some View { 42 | VStack { 43 | LazyPager(data: data, page: $index, direction: direction) { element in 44 | ZStack { 45 | Image(element.img) 46 | .resizable() 47 | .aspectRatio(contentMode: .fit) 48 | VStack { 49 | Text("\(index) \(element.idx) \(data.count - 1)") 50 | .foregroundColor(.black) 51 | .background(.white) 52 | } 53 | } 54 | } 55 | .zoomable(min: 1, max: 5) 56 | .onDismiss(backgroundOpacity: $opacity) { 57 | show = false 58 | } 59 | .onTap { 60 | print("tap") 61 | } 62 | .onDoubleTap { 63 | print("double tap") 64 | } 65 | .shouldLoadMore(on: .lastElement(minus: 2)) { 66 | data.append(Foo(img: "nora4", idx: data.count)) 67 | } 68 | .overscroll { position in 69 | if position == .beginning { 70 | print("Swiped past beginning") 71 | } else { 72 | print("Swiped past end") 73 | } 74 | } 75 | .onDrag { 76 | print("Drag") 77 | } 78 | .background(.black.opacity(opacity)) 79 | .background(ClearFullScreenBackground()) 80 | .ignoresSafeArea() 81 | VStack { 82 | HStack(spacing: 30) { 83 | Button("-") { 84 | index -= 1 85 | } 86 | VStack(spacing: 10) { 87 | Button("append") { 88 | data.append(Foo(img: "nora4", idx: data.count + 1)) 89 | } 90 | Button("replace") { 91 | data[0] = Foo(img: "nora4", idx: data.count + 1) 92 | } 93 | Button("update") { 94 | data[0].img = "nora5" 95 | } 96 | } 97 | VStack(spacing: 10) { 98 | Button("del first") { 99 | data.remove(at: 0) 100 | index -= 1 101 | } 102 | Button("del last") { 103 | data.remove(at: data.count - 1) 104 | } 105 | Button("jmp") { 106 | index = 10 107 | } 108 | } 109 | Button("+") { 110 | index += 1 111 | } 112 | } 113 | 114 | } 115 | .frame(maxWidth: .infinity) 116 | .background(.white) 117 | } 118 | } 119 | } 120 | 121 | struct FullTestView_Previews: PreviewProvider { 122 | static var previews: some View { 123 | FullTestView(direction: .horizontal, show: .constant(true)) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/LazyPagerExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LazyPagerApp.swift 3 | // LazyPager 4 | // 5 | // Created by Brian Floersch on 7/2/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct LazyPagerApp: App { 12 | @State var showFull = false 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | NavigationView { 17 | VStack(spacing: 20) { 18 | NavigationLink(destination: SimpleExample()) { 19 | Text("Simple Example") 20 | } 21 | NavigationLink(destination: EnvironmentExample() 22 | .environmentObject(TextHolder(str: "hello world")) 23 | .environment(\.customValue, "custom environment value") 24 | ) { 25 | Text("Environment Example") 26 | } 27 | NavigationLink(destination: AnimatedPagerControlsExample()) { 28 | Text("Animated Pager Controls Example") 29 | } 30 | Button("full Test View horizontal") { 31 | showFull.toggle() 32 | } 33 | NavigationLink(destination: FullTestView(direction: .vertical, show: .constant(true))) { 34 | Text("Full Test View vertical") 35 | } 36 | NavigationLink(destination: VerticalMediaPager()) { 37 | Text("Vertical media pager sample") 38 | } 39 | } 40 | } 41 | .fullScreenCover(isPresented: $showFull) { 42 | FullTestView(direction: .horizontal, show: $showFull) 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/SimpleExample.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import LazyPager 3 | 4 | 5 | struct SimpleExample: View { 6 | 7 | @State var data = [ 8 | "nora1", 9 | "nora2", 10 | "nora3", 11 | "nora4", 12 | "nora5", 13 | "nora6", 14 | ] 15 | 16 | @State var show = false 17 | 18 | var body: some View { 19 | LazyPager(data: data) { element in 20 | Image(element) 21 | .resizable() 22 | .aspectRatio(contentMode: .fit) 23 | } 24 | } 25 | } 26 | 27 | struct SimpleExample_Previews: PreviewProvider { 28 | static var previews: some View { 29 | SimpleExample() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleApp/VerticalMediaPager.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import LazyPager 3 | 4 | 5 | struct VerticalMediaPager: View { 6 | 7 | @State var data = [ 8 | "nora1", 9 | "nora2", 10 | "nora3", 11 | "nora4", 12 | "nora5", 13 | "nora6", 14 | ] 15 | @State var show = true 16 | 17 | var body: some View { 18 | ZStack { 19 | LazyPager(data: data, direction: .vertical) { element in 20 | Image(element) 21 | .resizable() 22 | .aspectRatio(contentMode: .fill) 23 | } 24 | .overscroll { 25 | if $0 == .beginning { 26 | print("Swiped past beginning") 27 | } else if $0 == .end { 28 | print("Swiped past end") 29 | } 30 | } 31 | .ignoresSafeArea() 32 | 33 | VStack(alignment: .leading) { 34 | HStack { 35 | Spacer() 36 | VStack(spacing: 30) { 37 | Spacer() 38 | imgButton("heart.fill") 39 | imgButton("text.bubble.fill") 40 | imgButton("bookmark.fill") 41 | imgButton("arrow.turn.up.right") 42 | } 43 | .padding(.bottom, 20) 44 | } 45 | .padding() 46 | Spacer() 47 | HStack { 48 | VStack { 49 | Text("CatTok") 50 | .frame(maxWidth: .infinity, alignment: .leading) 51 | .font(.title2) 52 | Text("Nora is an adorable cat") 53 | .frame(maxWidth: .infinity, alignment: .leading) 54 | } 55 | Spacer() 56 | Text("😸") 57 | .font(.title) 58 | .padding(5) 59 | .background(.pink.opacity(0.8)) 60 | .clipShape(Circle()) 61 | } 62 | .padding() 63 | .foregroundColor(.white) 64 | .background(.black.opacity(0.5)) 65 | } 66 | } 67 | } 68 | 69 | @ViewBuilder 70 | func imgButton(_ name: String) -> some View { 71 | Image(systemName: name) 72 | .resizable() 73 | .aspectRatio(contentMode: .fit) 74 | .frame(width: 40, height: 40) 75 | .foregroundColor(.white.opacity(0.9)) 76 | } 77 | } 78 | 79 | #Preview { 80 | VerticalMediaPager() 81 | } 82 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleAppTests/LazyPagerExampleAppTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LazyPagerTests.swift 3 | // LazyPagerTests 4 | // 5 | // Created by Brian Floersch on 7/2/23. 6 | // 7 | 8 | import XCTest 9 | @testable import LazyPager 10 | 11 | final class LazyPagerTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | // Any test you write for XCTest can be annotated as throws and async. 25 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 26 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. 27 | } 28 | 29 | func testPerformanceExample() throws { 30 | // This is an example of a performance test case. 31 | self.measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleAppUITests/ImageScrollViewUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LazyPagerUITests.swift 3 | // LazyPagerUITests 4 | // 5 | // Created by Brian Floersch on 7/2/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class LazyPagerUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use XCTAssert and related functions to verify your tests produce the correct results. 31 | } 32 | 33 | func testLaunchPerformance() throws { 34 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 35 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Examples/LazyPagerExampleAppUITests/ImageScrollViewUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LazyPagerUITestsLaunchTests.swift 3 | // LazyPagerUITests 4 | // 5 | // Created by Brian Floersch on 7/2/23. 6 | // 7 | 8 | import XCTest 9 | 10 | final class LazyPagerUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Brian Floersch 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: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "LazyPager", 8 | platforms: [ 9 | .iOS(.v15) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "LazyPager", 15 | targets: ["LazyPager"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "LazyPager", 26 | dependencies: []), 27 | .testTarget( 28 | name: "LazyPagerTests", 29 | dependencies: ["LazyPager"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LazyPager for SwiftUI 2 | 3 | A buttery smooth, lazy loaded, panning, zooming, and gesture dismissible view pager view for SwiftUI. 4 | 5 | The goal of this package is to expose a simple SwiftUI interface for a fluid and seamless content viewer. Unlike other pagers for SwiftUI - this is built on top of UIKit APIs exposing features not yet available in SwiftUI. 6 | 7 | ### Horizontal 8 |

9 | animated 10 |

11 | 12 | The above example is from [dateit](https://dateit.com/) demonstrating the capabilities of this library. Note: the overlay is custom and can be added by putting `LazyPager` inside a `ZStack`. 13 | 14 | ### Vertical 15 |

16 | animated 17 |

18 | 19 | The above example [can be found in the example project.](https://github.com/gh123man/SwiftUI-LazyPager/blob/master/Examples/LazyPagerExampleApp/VerticalMediaPager.swift) 20 | 21 | 22 | # Usage 23 | 24 | ## Add the Swift Package 25 | 26 | 1. Right click on your project -> `Add Package` 27 | 2. In the search bar paste: `https://github.com/gh123man/LazyPager` 28 | 3. Click `Add Package` 29 | 30 | Or add the package to your `Package.swift` if your project is a Swift package. 31 | 32 | 33 | ## Examples 34 | 35 | ### Simple Example 36 | A simple image pager that displays images by name from your app assets. 37 | 38 | ```swift 39 | @State var data = [ ... ] 40 | 41 | var body: some View { 42 | LazyPager(data: data) { element in 43 | Image(element) 44 | .resizable() 45 | .aspectRatio(contentMode: .fit) 46 | } 47 | } 48 | ``` 49 | 50 | That's it! 51 | 52 | ### Detailed Example 53 | 54 | ```swift 55 | @State var data = [ ... ] 56 | @State var show = true 57 | @State var opacity: CGFloat = 1 // Dismiss gesture background opacity 58 | @State var index = 0 59 | 60 | var body: some View { 61 | Button("Open") { 62 | show.toggle() 63 | } 64 | .fullScreenCover(isPresented: $show) { 65 | 66 | // Provide any list of data and bind to an index 67 | LazyPager(data: data, page: $index) { element in 68 | 69 | // Supports any kind of view - not only images 70 | Image(element) 71 | .resizable() 72 | .aspectRatio(contentMode: .fit) 73 | } 74 | 75 | // Make the content zoomable 76 | .zoomable(min: 1, max: 5) 77 | 78 | // Enable the swipe to dismiss gesture and background opacity control 79 | .onDismiss(backgroundOpacity: $opacity) { 80 | show = false 81 | } 82 | 83 | // Handle single tap gestures 84 | .onTap { 85 | print("tap") 86 | } 87 | 88 | // Get notified when to load more content 89 | .shouldLoadMore { 90 | data.append("foobar") 91 | } 92 | 93 | // Get notified when swiping past the beginning or end of the list 94 | .overscroll { position in 95 | if position == .beginning { 96 | print("Swiped past beginning") 97 | } else { 98 | print("Swiped past end") 99 | } 100 | } 101 | 102 | // Handle double tap gestures 103 | .onDoubleTap { 104 | print("double tap") 105 | } 106 | 107 | // Handle drag events initiated by the user 108 | .onDrag { 109 | print("Drag") 110 | } 111 | 112 | // Set the background color with the drag opacity control 113 | .background(.black.opacity(opacity)) 114 | 115 | // A special included modifier to help make fullScreenCover transparent 116 | .background(ClearFullScreenBackground()) 117 | 118 | // Works with safe areas or ignored safe areas 119 | .ignoresSafeArea() 120 | } 121 | } 122 | ``` 123 | 124 | #### Vertical paging 125 | 126 | ```swift 127 | @State var data = [ ... ] 128 | 129 | var body: some View { 130 | LazyPager(data: data, direction: .vertical) { element in 131 | Image(element) 132 | .resizable() 133 | .aspectRatio(contentMode: .fill) 134 | } 135 | } 136 | ``` 137 | 138 | For a full working example, [open the sample project](https://github.com/gh123man/LazyPager/tree/master/Examples) in the examples folder, or [check out the code here](https://github.com/gh123man/SwiftUI-LazyPager/blob/master/Examples/LazyPagerExampleApp/FullTestView.swift) 139 | 140 | # Features 141 | 142 | - All content is lazy loaded. By default content is pre-loaded 3 elements ahead and behind the current index. 143 | - Display any kind of content - not just images! 144 | - Horizontal or Vertical paging. 145 | - Lazy loaded views are disposed when they are outside of the pre-load frame to conserve resources. 146 | - Enable zooming and panning with `.zoomable(min: CGFloat, max: CGFloat)`. 147 | - Double tap to zoom is also supported through `.zoomable` modifier. 148 | - Notifies when to load more content with `.shouldLoadMore`. 149 | - Notifies when you swipe past the beginning or end of data with `.overscroll`. 150 | - Animate page transitions by using `withAnimation` when changing the page index. 151 | - Works with `.ignoresSafeArea()` (or not) to get a true full screen view. 152 | - Drag to dismiss is supported with `.onDismiss` - Supply a binding opacity value to control the background opacity during the transition. 153 | - Tap events are handled internally, so use `.onTap` to handle single taps (useful for hiding and showing UI). 154 | - Use `.onDoubleTap` to get notified on double taps. 155 | - Use `.settings` to [modify advanced settings](https://github.com/gh123man/SwiftUI-LazyPager/blob/master/Sources/LazyPager/LazyPager.swift#L73). 156 | - Use `.absoluteContentPosition` to subscribe to content position updates (the index + the offset while paging) 157 | - Use `.onZoom` to get notified of the current zoom level 158 | - Use `.onDrag` to handle drag events when the user interacts with the view. No triggered when page is changed programmatically. 159 | 160 | # Detailed usage 161 | 162 | ## Working with `fullScreenCover` 163 | 164 | `fullScreenCover` is a good native element for displaying a photo browser, however it has an opaque background by default that is difficult to remove. So `LazyPager` provides a `ClearFullScreenBackground` background view you can use to fix it. Simply add `.background(ClearFullScreenBackground())` to the root element of your `fullScreenCover`. This makes the pull to dismiss gesture seamless. 165 | 166 | ## Double tap to zoom 167 | You can customize the double tap behavior using the `zoomable(min: CGFloat, max: CGFloat, doubleTapGesture: DoubleTap)`. By default `doubleTapGesture` is set to `.scale(0.5)` which means "zoom 50% when double tapped". You can change this to a different ratio or set it to `.disabled` to disable the double tap gesture. 168 | 169 | ## Dismiss gesture handling 170 | By default `.onDismiss` will be called after the pull to dismiss gesture is completed. It is often desirable to fade out the background in the process. `LazyPager` uses a fully transparent background by default so you can set your own custom background. NOTE: `.onDismiss` is only supported for `.horizontal` pagers. 171 | 172 | To control the dismiss opacity of a custom background, use a `Binding` like `.onDismiss(backgroundOpacity: $opacity) {` to fade out your custom background. 173 | -------------------------------------------------------------------------------- /Sources/LazyPager/ClearFullScreenBackground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClearFullScreenBackground.swift 3 | // 4 | // 5 | // Created by Brian Floersch on 7/8/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import UIKit 11 | 12 | public struct ClearFullScreenBackground: UIViewRepresentable { 13 | 14 | public init() { } 15 | 16 | public func makeUIView(context: Context) -> UIView { 17 | let view = UIView() 18 | DispatchQueue.main.async { 19 | view.superview?.superview?.backgroundColor = .clear 20 | } 21 | return view 22 | } 23 | 24 | public func updateUIView(_ uiView: UIView, context: Context) {} 25 | } 26 | -------------------------------------------------------------------------------- /Sources/LazyPager/Collection+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Extensons.swift 3 | // 4 | // 5 | // Created by Brian Floersch on 7/8/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Collection { 11 | /// Returns the element at the specified index if it is within bounds, otherwise nil. 12 | subscript (safe index: Index) -> Element? { 13 | return indices.contains(index) ? self[index] : nil 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /Sources/LazyPager/LazyPager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LazyPager.swift 3 | // LazyPager 4 | // 5 | // Created by Brian Floersch on 7/6/23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import SwiftUI 11 | 12 | public enum LoadMore { 13 | case lastElement(minus: Int = 0) 14 | } 15 | 16 | public enum DoubleTap { 17 | case disabled 18 | case scale(CGFloat) 19 | } 20 | 21 | public enum Direction { 22 | case horizontal 23 | case vertical 24 | } 25 | 26 | public enum ListPosition { 27 | case beginning 28 | case end 29 | } 30 | 31 | public enum ZoomConfig { 32 | case disabled 33 | case custom(min: CGFloat, max: CGFloat, doubleTap: DoubleTap) 34 | } 35 | 36 | public struct Config { 37 | /// binding variable to control a custom background opacity. LazyPager is transparent by default 38 | public var backgroundOpacity: Binding? 39 | 40 | /// Called when the view is done dismissing - dismiss gesture is disabled if nil 41 | public var dismissCallback: (() -> ())? 42 | 43 | /// Called when tapping once 44 | public var tapCallback: (() -> ())? 45 | 46 | /// Called when tapping twice 47 | public var doubleTapCallback: (() -> ())? 48 | 49 | /// Called when dragging begins 50 | public var dragCallback: (() -> ())? 51 | 52 | /// The offset used to trigger load loadMoreCallback 53 | public var loadMoreOn: LoadMore = .lastElement(minus: 3) 54 | 55 | /// Called when more content should be loaded 56 | public var loadMoreCallback: (() -> ())? 57 | 58 | /// Direction of the pager 59 | public var direction : Direction = .horizontal 60 | 61 | /// Called whent the end of data is reached and the user tries to swipe again 62 | public var overscrollCallback: ((ListPosition) -> ())? 63 | 64 | /// The element index + the offset while paging 65 | public var absoluteContentPosition: Binding? 66 | 67 | /// Called every view update to get the zoom config 68 | public var zoomConfigGetter: (Element) -> ZoomConfig = { _ in .disabled } 69 | 70 | /// Called while zooming to provide the current zoom level for an element 71 | public var onZoomHandler: ((Element, CGFloat) -> ())? 72 | 73 | /// Advanced settings (only accessibleevia .settings) 74 | 75 | /// How may out of view pages to load in advance (forward and backwards) 76 | public var preloadAmount: Int = 3 77 | 78 | /// Minimum swipe velocity needed to trigger a dismiss 79 | public var dismissVelocity: CGFloat = 1.3 80 | 81 | /// the minimum % (between 0 and 1) you need to drag to trigger a dismiss 82 | public var dismissTriggerOffset: CGFloat = 0.1 83 | 84 | /// How long to animate the dismiss once done dragging 85 | public var dismissAnimationLength: CGFloat = 0.2 86 | 87 | /// Cancel SwiftUI animations. Default to true because the dismiss gesture is already animated. 88 | /// Stacking animations can cause undesirable behavior 89 | public var shouldCancelSwiftUIAnimationsOnDismiss = true 90 | 91 | /// At what drag % (between 0 and 1) the background should be fully transparent 92 | public var fullFadeOnDragAt: CGFloat = 0.2 93 | 94 | /// The minimum scroll distance the in which the pinch gesture is enabled 95 | public var pinchGestureEnableOffset: Double = 10 96 | 97 | /// % ammount (from 0-1) of overscroll needed to call overscrollCallback 98 | public var overscrollThreshold: Double = 0.15 99 | } 100 | 101 | public struct LazyPager where DataCollecton.Index == Int, DataCollecton.Element == Element { 102 | private var viewLoader: (Element) -> Content 103 | private var data: DataCollecton 104 | 105 | @State private var defaultPageInternal = 0 106 | private var providedPage: Binding? 107 | 108 | private var page: Binding { 109 | providedPage ?? Binding( 110 | get: { defaultPageInternal }, 111 | set: { defaultPageInternal = $0 } 112 | ) 113 | } 114 | 115 | var config = Config() 116 | 117 | public init(data: DataCollecton, 118 | page: Binding? = nil, 119 | direction: Direction = .horizontal, 120 | @ViewBuilder content: @escaping (Element) -> Content) { 121 | self.data = data 122 | self.providedPage = page 123 | self.viewLoader = content 124 | self.config.direction = direction 125 | } 126 | } 127 | 128 | public extension LazyPager { 129 | func onDismiss(backgroundOpacity: Binding? = nil, _ callback: @escaping () -> ()) -> LazyPager { 130 | guard config.direction == .horizontal else { 131 | return self 132 | } 133 | var this = self 134 | this.config.backgroundOpacity = backgroundOpacity 135 | this.config.dismissCallback = callback 136 | return this 137 | } 138 | 139 | func onTap(_ callback: @escaping () -> ()) -> LazyPager { 140 | var this = self 141 | this.config.tapCallback = callback 142 | return this 143 | } 144 | 145 | func onDoubleTap(_ callback: @escaping () -> ()) -> LazyPager { 146 | var this = self 147 | this.config.doubleTapCallback = callback 148 | return this 149 | } 150 | 151 | func onDrag(_ callback: @escaping () -> ()) -> LazyPager { 152 | var this = self 153 | this.config.dragCallback = callback 154 | return this 155 | } 156 | 157 | func shouldLoadMore(on: LoadMore = .lastElement(minus: 3), _ callback: @escaping () -> ()) -> LazyPager { 158 | var this = self 159 | this.config.loadMoreOn = on 160 | this.config.loadMoreCallback = callback 161 | return this 162 | } 163 | 164 | func zoomable(min: CGFloat, max: CGFloat, doubleTapGesture: DoubleTap = .scale(0.5)) -> LazyPager { 165 | var this = self 166 | this.config.zoomConfigGetter = { _ in 167 | return .custom(min: min, max: max, doubleTap: doubleTapGesture) 168 | } 169 | return this 170 | } 171 | 172 | func zoomable(onElement: @escaping (Element) -> ZoomConfig) -> LazyPager { 173 | var this = self 174 | this.config.zoomConfigGetter = onElement 175 | return this 176 | } 177 | 178 | func settings(_ adjust: @escaping (inout Config) -> ()) -> LazyPager { 179 | var this = self 180 | adjust(&this.config) 181 | return this 182 | } 183 | 184 | func overscroll(_ callback: @escaping (ListPosition) -> ()) -> LazyPager { 185 | var this = self 186 | this.config.overscrollCallback = callback 187 | return this 188 | } 189 | 190 | func absoluteContentPosition(_ absoluteContentPosition: Binding? = nil) -> LazyPager { 191 | guard config.direction == .horizontal else { 192 | return self 193 | } 194 | var this = self 195 | this.config.absoluteContentPosition = absoluteContentPosition 196 | return this 197 | } 198 | 199 | func onZoom(_ onZoomHandler: @escaping (Element, CGFloat) -> ()) -> LazyPager { 200 | var this = self 201 | this.config.onZoomHandler = onZoomHandler 202 | return this 203 | } 204 | } 205 | 206 | extension LazyPager: UIViewControllerRepresentable { 207 | public func makeUIViewController(context: Context) -> ViewDataProvider { 208 | let provider = ViewDataProvider(data: data, 209 | page: page, 210 | config: config, 211 | viewLoader: viewLoader) 212 | DispatchQueue.main.async { 213 | provider.goToPage(page.wrappedValue, animated: false) 214 | } 215 | return provider 216 | } 217 | 218 | public func updateUIViewController(_ uiViewController: ViewDataProvider, context: Context) { 219 | uiViewController.viewLoader = viewLoader 220 | uiViewController.data = data 221 | defer { uiViewController.reloadViews() } 222 | if page.wrappedValue != uiViewController.pagerView.currentIndex { 223 | // Index was explicitly updated 224 | uiViewController.goToPage(page.wrappedValue, animated: context.transaction.animation != nil) 225 | } 226 | 227 | if page.wrappedValue >= data.count { 228 | uiViewController.goToPage(data.count - 1, animated: false) 229 | } else if page.wrappedValue < 0 { 230 | uiViewController.goToPage(0, animated: false) 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /Sources/LazyPager/Math.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Math.swift 3 | // 4 | // 5 | // Created by Brian Floersch on 7/8/23. 6 | // 7 | 8 | import Foundation 9 | 10 | func lerp(from: CGFloat, to: CGFloat, by: CGFloat) -> CGFloat { 11 | return from * (1 - by) + to * by 12 | } 13 | 14 | func normalize(from min: CGFloat, at val: CGFloat, to max: CGFloat) -> CGFloat { 15 | let v = (val - min) / (max - min) 16 | return v < 0 ? 0 : v > 1 ? 1 : v 17 | } 18 | 19 | -------------------------------------------------------------------------------- /Sources/LazyPager/PagerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PagerView.swift 3 | // 4 | // 5 | // Created by Brian Floersch on 7/8/23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import SwiftUI 11 | 12 | protocol ViewLoader: AnyObject { 13 | 14 | associatedtype Element 15 | associatedtype Content: View 16 | 17 | var dataCount: Int { get } 18 | 19 | func loadView(at: Int) -> ZoomableView? 20 | func updateHostedView(for zoomableView: ZoomableView) 21 | } 22 | 23 | class PagerView: UIScrollView, UIScrollViewDelegate where Loader.Element == Element, Loader.Content == Content { 24 | 25 | var isFirstLoad = false 26 | var loadedViews = [ZoomableView]() 27 | var config: Config 28 | weak var viewLoader: Loader? 29 | 30 | var isRotating = false 31 | var page: Binding 32 | 33 | var currentIndex: Int = 0 { 34 | didSet { 35 | computeViewState() 36 | loadMoreIfNeeded() 37 | } 38 | } 39 | 40 | var absoluteOffset: CGFloat { 41 | var absoluteOffset: CGFloat 42 | if config.direction == .horizontal { 43 | absoluteOffset = self.contentOffset.x / self.frame.width 44 | } else { 45 | absoluteOffset = self.contentOffset.y / self.frame.height 46 | } 47 | return absoluteOffset 48 | } 49 | 50 | var relativeIndex: Int { 51 | var idx = Int(round(absoluteOffset)) 52 | idx = idx < 0 ? 0 : idx 53 | idx = idx >= loadedViews.count ? loadedViews.count-1 : idx 54 | return idx 55 | } 56 | 57 | var currentView: ZoomableView { 58 | loadedViews[relativeIndex] 59 | } 60 | 61 | init(page: Binding, config: Config) { 62 | self.currentIndex = page.wrappedValue 63 | self.page = page 64 | self.config = config 65 | super.init(frame: .zero) 66 | 67 | showsVerticalScrollIndicator = false 68 | showsHorizontalScrollIndicator = false 69 | backgroundColor = .clear 70 | isPagingEnabled = true 71 | delegate = self 72 | } 73 | 74 | 75 | required init?(coder: NSCoder) { 76 | fatalError("Not implemented") 77 | } 78 | 79 | public override func layoutSubviews() { 80 | super.layoutSubviews() 81 | if !isFirstLoad { 82 | ensureCurrentPage(animated: false) 83 | isFirstLoad = true 84 | } else if isRotating { 85 | ensureCurrentPage(animated: false) 86 | } 87 | } 88 | 89 | func computeViewState() { 90 | delegate = nil 91 | DispatchQueue.main.async { 92 | self.delegate = self 93 | } 94 | 95 | if subviews.isEmpty { 96 | for i in currentIndex...(currentIndex + config.preloadAmount) { 97 | appendView(at: i) 98 | } 99 | for i in ((currentIndex - config.preloadAmount)..) { 129 | super.addSubview(zoomView) 130 | NSLayoutConstraint.activate([ 131 | zoomView.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor), 132 | zoomView.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor), 133 | ]) 134 | } 135 | 136 | func addFirstView(_ zoomView: ZoomableView) { 137 | if config.direction == .horizontal { 138 | zoomView.leadingConstraint = zoomView.leadingAnchor.constraint(equalTo: leadingAnchor) 139 | zoomView.trailingConstraint = zoomView.trailingAnchor.constraint(equalTo: trailingAnchor) 140 | zoomView.leadingConstraint?.isActive = true 141 | zoomView.trailingConstraint?.isActive = true 142 | } else { 143 | zoomView.topConstraint = zoomView.topAnchor.constraint(equalTo: topAnchor) 144 | zoomView.bottomConstraint = zoomView.bottomAnchor.constraint(equalTo: bottomAnchor) 145 | zoomView.topConstraint?.isActive = true 146 | zoomView.bottomConstraint?.isActive = true 147 | 148 | } 149 | 150 | } 151 | 152 | func appendView(at index: Int) { 153 | guard let zoomView = viewLoader?.loadView(at: index) else { return } 154 | 155 | addSubview(zoomView) 156 | 157 | if let lastView = loadedViews.last { 158 | if config.direction == .horizontal { 159 | lastView.trailingConstraint?.isActive = false 160 | lastView.trailingConstraint = nil 161 | 162 | zoomView.leadingConstraint = zoomView.leadingAnchor.constraint(equalTo: lastView.trailingAnchor) 163 | zoomView.trailingConstraint = zoomView.trailingAnchor.constraint(equalTo: trailingAnchor) 164 | zoomView.leadingConstraint?.isActive = true 165 | zoomView.trailingConstraint?.isActive = true 166 | } else { 167 | lastView.bottomConstraint?.isActive = false 168 | lastView.bottomConstraint = nil 169 | 170 | zoomView.topConstraint = zoomView.topAnchor.constraint(equalTo: lastView.bottomAnchor) 171 | zoomView.bottomConstraint = zoomView.bottomAnchor.constraint(equalTo: bottomAnchor) 172 | zoomView.topConstraint?.isActive = true 173 | zoomView.bottomConstraint?.isActive = true 174 | } 175 | 176 | } else { 177 | addFirstView(zoomView) 178 | } 179 | loadedViews.append(zoomView) 180 | } 181 | 182 | func prependView(at index: Int) { 183 | guard let zoomView = viewLoader?.loadView(at: index) else { return } 184 | 185 | addSubview(zoomView) 186 | 187 | if let firstView = loadedViews.first { 188 | if config.direction == .horizontal { 189 | firstView.leadingConstraint?.isActive = false 190 | firstView.leadingConstraint = nil 191 | 192 | zoomView.leadingConstraint = zoomView.leadingAnchor.constraint(equalTo: leadingAnchor) 193 | zoomView.trailingConstraint = zoomView.trailingAnchor.constraint(equalTo: firstView.leadingAnchor) 194 | zoomView.leadingConstraint?.isActive = true 195 | zoomView.trailingConstraint?.isActive = true 196 | } else { 197 | firstView.topConstraint?.isActive = false 198 | firstView.topConstraint = nil 199 | 200 | zoomView.topConstraint = zoomView.topAnchor.constraint(equalTo: topAnchor) 201 | zoomView.bottomConstraint = zoomView.bottomAnchor.constraint(equalTo: firstView.topAnchor) 202 | zoomView.topConstraint?.isActive = true 203 | zoomView.bottomConstraint?.isActive = true 204 | } 205 | 206 | } else { 207 | addFirstView(zoomView) 208 | } 209 | 210 | loadedViews.insert(zoomView, at: 0) 211 | if config.direction == .horizontal { 212 | contentOffset.x += frame.size.width 213 | } else { 214 | contentOffset.y += frame.size.height 215 | } 216 | } 217 | 218 | func reloadViews() { 219 | for view in loadedViews { 220 | viewLoader?.updateHostedView(for: view) 221 | } 222 | } 223 | 224 | func remove(view: ZoomableView) { 225 | let index = view.index 226 | loadedViews.removeAll { $0.index == view.index } 227 | view.removeFromSuperview() 228 | 229 | if let firstView = loadedViews.first { 230 | 231 | if config.direction == .horizontal { 232 | firstView.leadingConstraint?.isActive = false 233 | firstView.leadingConstraint = nil 234 | firstView.leadingConstraint = firstView.leadingAnchor.constraint(equalTo: leadingAnchor) 235 | firstView.leadingConstraint?.isActive = true 236 | 237 | if firstView.index > index { 238 | contentOffset.x -= frame.size.width 239 | } 240 | } else { 241 | firstView.topConstraint?.isActive = false 242 | firstView.topConstraint = nil 243 | firstView.topConstraint = firstView.topAnchor.constraint(equalTo: topAnchor) 244 | firstView.topConstraint?.isActive = true 245 | 246 | if firstView.index > index { 247 | contentOffset.y -= frame.size.height 248 | } 249 | } 250 | } 251 | 252 | if let lastView = loadedViews.last { 253 | if config.direction == .horizontal { 254 | lastView.trailingConstraint?.isActive = false 255 | lastView.trailingConstraint = nil 256 | lastView.trailingConstraint = lastView.trailingAnchor.constraint(equalTo: trailingAnchor) 257 | lastView.trailingConstraint?.isActive = true 258 | } else { 259 | lastView.bottomConstraint?.isActive = false 260 | lastView.bottomConstraint = nil 261 | lastView.bottomConstraint = lastView.bottomAnchor.constraint(equalTo: bottomAnchor) 262 | lastView.bottomConstraint?.isActive = true 263 | } 264 | } 265 | } 266 | 267 | 268 | func removeOutOfFrameViews() { 269 | guard let viewLoader = viewLoader else { return } 270 | 271 | for view in loadedViews { 272 | if abs(currentIndex - view.index) > config.preloadAmount || view.index >= viewLoader.dataCount { 273 | remove(view: view) 274 | } 275 | } 276 | } 277 | 278 | func resizeOutOfBoundsViews() { 279 | for v in loadedViews { 280 | if v.index != currentIndex { 281 | v.zoomScale = 1 282 | } 283 | } 284 | } 285 | 286 | func goToPage(_ page: Int, animated: Bool) { 287 | currentIndex = page 288 | DispatchQueue.main.async { 289 | self.ensureCurrentPage(animated: animated) 290 | } 291 | } 292 | 293 | func ensureCurrentPage(animated: Bool) { 294 | guard let index = loadedViews.firstIndex(where: { $0.index == currentIndex }) else { return } 295 | if config.direction == .horizontal { 296 | setContentOffset(CGPoint(x: CGFloat(index) * frame.size.width, y: contentOffset.y), animated: animated) 297 | } else { 298 | setContentOffset(CGPoint(x: contentOffset.x, y: CGFloat(index) * frame.size.height), animated: animated) 299 | } 300 | } 301 | 302 | func loadMoreIfNeeded() { 303 | guard let loadMoreCallback = config.loadMoreCallback else { return } 304 | guard case let .lastElement(offset) = config.loadMoreOn else { return } 305 | guard let viewLoader = viewLoader else { return } 306 | 307 | if currentIndex + offset >= viewLoader.dataCount - 1 { 308 | DispatchQueue.main.async { 309 | loadMoreCallback() 310 | } 311 | } 312 | } 313 | 314 | // MARK: UISCrollVieDelegate methods 315 | 316 | var lastPos: CGFloat = 0 317 | var hasNotfiedOverscroll = false 318 | var scrollSettled = true 319 | 320 | func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { 321 | config.dragCallback?() 322 | } 323 | 324 | public func scrollViewDidScroll(_ scrollView: UIScrollView) { 325 | if !scrollView.isTracking, !isRotating, (currentView.index != page.wrappedValue || page.wrappedValue != currentIndex ) { 326 | currentIndex = currentView.index 327 | page.wrappedValue = currentIndex 328 | } 329 | 330 | if let index = loadedViews[safe: relativeIndex]?.index { 331 | config.absoluteContentPosition?.wrappedValue = CGFloat(index) + absoluteOffset - CGFloat(relativeIndex) 332 | } 333 | 334 | if !hasNotfiedOverscroll { 335 | if relativeIndex >= loadedViews.count-1, absoluteOffset - CGFloat(relativeIndex) > config.overscrollThreshold { 336 | config.overscrollCallback?(.end) 337 | hasNotfiedOverscroll = true 338 | } 339 | 340 | if relativeIndex <= 0, absoluteOffset - CGFloat(relativeIndex) < -config.overscrollThreshold { 341 | config.overscrollCallback?(.beginning) 342 | hasNotfiedOverscroll = true 343 | } 344 | } 345 | 346 | // Horribly janky way to detect when scrolling (both touching and animation) is finnished. 347 | let caputred: CGFloat 348 | 349 | if config.direction == .horizontal { 350 | caputred = scrollView.contentOffset.x 351 | } else { 352 | caputred = scrollView.contentOffset.y 353 | } 354 | 355 | lastPos = caputred 356 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { 357 | if self.lastPos == caputred, !scrollView.isTracking { 358 | self.hasNotfiedOverscroll = false 359 | self.resizeOutOfBoundsViews() 360 | self.scrollSettled = true 361 | } 362 | } 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /Sources/LazyPager/ViewDataProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewDataProvider.swift 3 | // 4 | // 5 | // Created by Brian Floersch on 7/8/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import UIKit 11 | 12 | public class ViewDataProvider: UIViewController, ViewLoader where DataCollecton.Index == Int, DataCollecton.Element == Element { 13 | 14 | var viewLoader: (Element) -> Content 15 | var data: DataCollecton 16 | var config: Config 17 | var pagerView: PagerView 18 | 19 | var dataCount: Int { 20 | return data.count 21 | } 22 | 23 | init(data: DataCollecton, 24 | page: Binding, 25 | config: Config, 26 | viewLoader: @escaping (Element) -> Content) { 27 | 28 | self.data = data 29 | self.viewLoader = viewLoader 30 | self.config = config 31 | self.pagerView = PagerView(page: page, config: config) 32 | 33 | super.init(nibName: nil, bundle: nil) 34 | self.pagerView.viewLoader = self 35 | 36 | pagerView.computeViewState() 37 | } 38 | 39 | required init?(coder: NSCoder) { 40 | fatalError("init(coder:) has not been implemented") 41 | } 42 | 43 | func goToPage(_ page: Int, animated: Bool) { 44 | pagerView.goToPage(page, animated: animated) 45 | } 46 | 47 | func reloadViews() { 48 | pagerView.reloadViews() 49 | pagerView.computeViewState() 50 | } 51 | 52 | // MARK: ViewLoader 53 | 54 | func loadView(at index: Int) -> ZoomableView? { 55 | guard let dta = data[safe: index] else { return nil } 56 | 57 | let hostingController = UIHostingController(rootView: viewLoader(dta)) 58 | return ZoomableView(hostingController: hostingController, index: index, data: dta, config: config) 59 | } 60 | 61 | func updateHostedView(for zoomableView: ZoomableView) { 62 | guard let dta = data[safe: zoomableView.index] else { return } 63 | 64 | zoomableView.hostingController.rootView = viewLoader(dta) 65 | } 66 | 67 | // MARK: UIViewController 68 | 69 | public override func loadView() { 70 | self.view = pagerView 71 | } 72 | 73 | public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 74 | super.viewWillTransition(to: size, with: coordinator) 75 | 76 | pagerView.isRotating = true 77 | coordinator.animate(alongsideTransition: { context in }, completion: { context in 78 | self.pagerView.isRotating = false 79 | DispatchQueue.main.async { 80 | self.pagerView.goToPage(self.pagerView.currentIndex, animated: false) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /Sources/LazyPager/ZoomableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZoomableView.swift 3 | // LazyPager 4 | // 5 | // Created by Brian Floersch on 7/4/23. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import SwiftUI 11 | 12 | class ZoomableView: UIScrollView, UIScrollViewDelegate { 13 | 14 | var trailingConstraint: NSLayoutConstraint? 15 | var leadingConstraint: NSLayoutConstraint? 16 | var topConstraint: NSLayoutConstraint? 17 | var bottomConstraint: NSLayoutConstraint? 18 | var contentTopToContent: NSLayoutConstraint! 19 | var contentTopToFrame: NSLayoutConstraint! 20 | var contentBottomToFrame: NSLayoutConstraint! 21 | var contentBottomToView: NSLayoutConstraint! 22 | 23 | var config: Config 24 | var bottomView: UIView 25 | 26 | var allowScroll: Bool = true { 27 | didSet { 28 | if allowScroll, config.direction == .horizontal { 29 | contentTopToFrame.isActive = false 30 | contentBottomToFrame.isActive = false 31 | bottomView.isHidden = false 32 | 33 | contentTopToContent.isActive = true 34 | contentBottomToView.isActive = true 35 | } else { 36 | contentTopToContent.isActive = false 37 | contentBottomToView.isActive = false 38 | 39 | contentTopToFrame.isActive = true 40 | contentBottomToFrame.isActive = true 41 | bottomView.isHidden = true 42 | } 43 | } 44 | } 45 | 46 | var wasTracking = false 47 | var isAnimating = false 48 | var isZoomHappening = false 49 | var lastInset: CGFloat = 0 50 | 51 | var hostingController: UIHostingController 52 | var index: Int 53 | var data: Element 54 | var doubleTap: DoubleTap? 55 | 56 | var view: UIView { 57 | return hostingController.view 58 | } 59 | 60 | init(hostingController: UIHostingController, index: Int, data: Element, config: Config) { 61 | self.index = index 62 | self.hostingController = hostingController 63 | self.data = data 64 | self.config = config 65 | 66 | let v = UIView() 67 | self.bottomView = v 68 | 69 | super.init(frame: .zero) 70 | 71 | translatesAutoresizingMaskIntoConstraints = false 72 | delegate = self 73 | 74 | updateZoomConfig() 75 | 76 | bouncesZoom = true 77 | backgroundColor = .clear 78 | alwaysBounceVertical = false 79 | contentInsetAdjustmentBehavior = .always 80 | if config.dismissCallback != nil { 81 | alwaysBounceVertical = true 82 | } 83 | showsVerticalScrollIndicator = false 84 | showsHorizontalScrollIndicator = false 85 | 86 | view.translatesAutoresizingMaskIntoConstraints = false 87 | view.backgroundColor = .clear 88 | addSubview(view) 89 | 90 | NSLayoutConstraint.activate([ 91 | view.leadingAnchor.constraint(equalTo: leadingAnchor), 92 | view.trailingAnchor.constraint(equalTo: trailingAnchor), 93 | view.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor), 94 | view.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor), 95 | ]) 96 | 97 | contentTopToFrame = view.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor) 98 | contentTopToContent = view.topAnchor.constraint(equalTo: topAnchor) 99 | contentBottomToFrame = view.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor) 100 | contentBottomToView = view.bottomAnchor.constraint(equalTo: bottomView.topAnchor) 101 | 102 | v.translatesAutoresizingMaskIntoConstraints = false 103 | addSubview(v) 104 | 105 | // This is for future support of a drawer view 106 | let constant: CGFloat = config.dismissCallback == nil ? 0 : 1 107 | 108 | NSLayoutConstraint.activate([ 109 | v.bottomAnchor.constraint(equalTo: bottomAnchor), 110 | v.leadingAnchor.constraint(equalTo: frameLayoutGuide.leadingAnchor), 111 | v.trailingAnchor.constraint(equalTo: frameLayoutGuide.trailingAnchor), 112 | v.heightAnchor.constraint(equalToConstant: constant) 113 | ]) 114 | 115 | var singleTapGesture: UITapGestureRecognizer? 116 | if config.tapCallback != nil { 117 | let gesture = UITapGestureRecognizer(target: self, action: #selector(singleTap(_:))) 118 | gesture.numberOfTapsRequired = 1 119 | gesture.numberOfTouchesRequired = 1 120 | addGestureRecognizer(gesture) 121 | singleTapGesture = gesture 122 | } 123 | 124 | func setupDoubleTapGesture() { 125 | let doubleTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(onDoubleTap(_:))) 126 | doubleTapRecognizer.numberOfTapsRequired = 2 127 | doubleTapRecognizer.numberOfTouchesRequired = 1 128 | addGestureRecognizer(doubleTapRecognizer) 129 | singleTapGesture?.require(toFail: doubleTapRecognizer) 130 | } 131 | 132 | if case .scale = doubleTap { 133 | setupDoubleTapGesture() 134 | } else if config.doubleTapCallback != nil { 135 | setupDoubleTapGesture() 136 | } 137 | 138 | DispatchQueue.main.async { 139 | self.updateState() 140 | } 141 | } 142 | 143 | required init?(coder: NSCoder) { 144 | fatalError("Not implemented") 145 | } 146 | 147 | func updateZoomConfig() { 148 | switch config.zoomConfigGetter(data) { 149 | case .disabled: 150 | maximumZoomScale = 1 151 | minimumZoomScale = 1 152 | doubleTap = nil 153 | case let .custom(min, max, doubleTap): 154 | minimumZoomScale = min 155 | maximumZoomScale = max 156 | self.doubleTap = doubleTap 157 | } 158 | } 159 | 160 | @objc func singleTap(_ recognizer: UITapGestureRecognizer) { 161 | config.tapCallback?() 162 | } 163 | 164 | @objc func onDoubleTap(_ recognizer: UITapGestureRecognizer) { 165 | config.doubleTapCallback?() 166 | 167 | if case let .scale(scale) = doubleTap { 168 | let pointInView = recognizer.location(in: view) 169 | zoom(at: pointInView, scale: scale) 170 | } 171 | } 172 | 173 | func updateState() { 174 | 175 | updateZoomConfig() 176 | allowScroll = zoomScale == 1 177 | 178 | if contentOffset.y > config.pinchGestureEnableOffset, allowScroll { 179 | pinchGestureRecognizer?.isEnabled = false 180 | } else { 181 | pinchGestureRecognizer?.isEnabled = true 182 | } 183 | 184 | if allowScroll { 185 | // Counteract content inset adjustments. Makes .ignoresSafeArea() work 186 | contentInset = UIEdgeInsets(top: -safeAreaInsets.top, left: -safeAreaInsets.left, bottom: -safeAreaInsets.bottom, right: -safeAreaInsets.right) 187 | 188 | if !isAnimating, config.dismissCallback != nil { 189 | let offset = contentOffset.y 190 | if offset < 0 { 191 | let absoluteDragOffset = normalize(from: 0, at: abs(offset), to: frame.size.height) 192 | let fadeOffset = normalize(from: 0, at: absoluteDragOffset, to: config.fullFadeOnDragAt) 193 | config.backgroundOpacity?.wrappedValue = 1 - fadeOffset 194 | } else { 195 | DispatchQueue.main.async { 196 | self.config.backgroundOpacity?.wrappedValue = 1 197 | } 198 | } 199 | } 200 | 201 | wasTracking = isTracking 202 | } 203 | } 204 | 205 | func zoom(at point: CGPoint, scale: CGFloat) { 206 | let mid = lerp(from: minimumZoomScale, to: maximumZoomScale, by: scale) 207 | let newZoomScale = zoomScale == minimumZoomScale ? mid : minimumZoomScale 208 | let size = bounds.size 209 | let w = size.width / newZoomScale 210 | let h = size.height / newZoomScale 211 | let x = point.x - (w * 0.5) 212 | let y = point.y - (h * 0.5) 213 | zoom(to: CGRect(x: x, y: y, width: w, height: h), animated: true) 214 | } 215 | 216 | override func layoutSubviews() { 217 | super.layoutSubviews() 218 | 219 | scrollViewDidZoom(self) 220 | } 221 | 222 | // MARK: UIScrollViewDelegate methods 223 | 224 | func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { 225 | isZoomHappening = true 226 | updateState() 227 | } 228 | 229 | func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { 230 | isZoomHappening = false 231 | updateState() 232 | } 233 | 234 | func scrollViewDidScroll(_ scrollView: UIScrollView) { 235 | updateState() 236 | } 237 | 238 | func viewForZooming(in scrollView: UIScrollView) -> UIView? { 239 | return view 240 | } 241 | 242 | func scrollViewDidZoom(_ scrollView: UIScrollView) { 243 | 244 | let w: CGFloat = view.intrinsicContentSize.width * UIScreen.main.scale 245 | let h: CGFloat = view.intrinsicContentSize.height * UIScreen.main.scale 246 | 247 | let ratioW = view.frame.width / w 248 | let ratioH = view.frame.height / h 249 | 250 | let ratio = ratioW < ratioH ? ratioW : ratioH 251 | 252 | let newWidth = w*ratio 253 | let newHeight = h*ratio 254 | 255 | let left = 0.5 * (newWidth * scrollView.zoomScale > view.frame.width 256 | ? (newWidth - view.frame.width) 257 | : (scrollView.frame.width - view.frame.width)) 258 | let top = 0.5 * (newHeight * scrollView.zoomScale > view.frame.height 259 | ? (newHeight - view.frame.height) 260 | : (scrollView.frame.height - view.frame.height)) 261 | 262 | if zoomScale <= maximumZoomScale { 263 | contentInset = UIEdgeInsets(top: top - safeAreaInsets.top, left: left, bottom: top - safeAreaInsets.bottom, right: left) 264 | } 265 | 266 | config.onZoomHandler?(data, scrollView.zoomScale) 267 | } 268 | 269 | func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { 270 | 271 | let percentage = contentOffset.y / (contentSize.height - bounds.size.height) 272 | 273 | if wasTracking, 274 | percentage < -config.dismissTriggerOffset, 275 | !isZoomHappening, 276 | velocity.y < -config.dismissVelocity, 277 | config.dismissCallback != nil { 278 | 279 | isAnimating = true 280 | let ogFram = frame.origin 281 | 282 | withAnimation(.linear(duration: self.config.dismissAnimationLength)) { 283 | self.config.backgroundOpacity?.wrappedValue = 0 284 | } 285 | 286 | UIView.animate(withDuration: self.config.dismissAnimationLength, animations: { 287 | self.frame.origin = CGPoint(x: ogFram.x, y: self.frame.size.height) 288 | }) { _ in 289 | if self.config.shouldCancelSwiftUIAnimationsOnDismiss { 290 | var transaction = Transaction() 291 | transaction.disablesAnimations = true 292 | withTransaction(transaction) { 293 | self.config.dismissCallback?() 294 | } 295 | } else { 296 | self.config.dismissCallback?() 297 | } 298 | } 299 | } 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /Tests/LazyPagerTests/LazyPagerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import LazyPager 3 | 4 | final class LazyPagerTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | // XCTAssertEqual(LazyPager().text, "Hello, World!") 10 | } 11 | } 12 | --------------------------------------------------------------------------------