Repository: gh123man/SwiftUI-LazyPager Branch: master Commit: be4b1952c105 Files: 36 Total size: 99.8 KB Directory structure: gitextract_8knwvjye/ ├── .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/ │ │ │ │ └── Contents.json │ │ │ ├── nora2.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── nora3.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── nora4.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── nora5.imageset/ │ │ │ │ └── Contents.json │ │ │ └── nora6.imageset/ │ │ │ └── Contents.json │ │ ├── EnvironmentExample.swift │ │ ├── FullTestView.swift │ │ ├── InsetTest.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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** A working minimal code example is preferred! If you cannot produce example code please list the steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Screenshots** If applicable, add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. ================================================ FILE: .gitignore ================================================ .DS_Store /.build /Packages /*.xcodeproj xcuserdata/ DerivedData/ .swiftpm/config/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc ================================================ FILE: Examples/LazyPagerExample.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 60; objects = { /* Begin PBXBuildFile section */ D309D3802E88B66E007A6FDC /* InsetTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D309D37F2E88B66E007A6FDC /* InsetTest.swift */; }; D33BC0522B69E6EE004B4338 /* SimpleExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33BC0512B69E6EE004B4338 /* SimpleExample.swift */; }; D33F96FD2C62F582004D934A /* VerticalMediaPager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33F96FC2C62F582004D934A /* VerticalMediaPager.swift */; }; D353FB592A52174B00C04ABE /* LazyPagerExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB582A52174B00C04ABE /* LazyPagerExampleApp.swift */; }; D353FB5B2A52174B00C04ABE /* FullTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB5A2A52174B00C04ABE /* FullTestView.swift */; }; D353FB5D2A52174C00C04ABE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D353FB5C2A52174C00C04ABE /* Assets.xcassets */; }; D353FB602A52174C00C04ABE /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D353FB5F2A52174C00C04ABE /* Preview Assets.xcassets */; }; D353FB6A2A52174C00C04ABE /* LazyPagerExampleAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB692A52174C00C04ABE /* LazyPagerExampleAppTests.swift */; }; D353FB742A52174C00C04ABE /* ImageScrollViewUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB732A52174C00C04ABE /* ImageScrollViewUITests.swift */; }; D353FB762A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D353FB752A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift */; }; D367DA132A59E930004497D4 /* LazyPager in Frameworks */ = {isa = PBXBuildFile; productRef = D367DA122A59E930004497D4 /* LazyPager */; }; D3776B5F2CF5658500AFB89D /* AnimatedPagerControlsExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3776B5E2CF5658500AFB89D /* AnimatedPagerControlsExample.swift */; }; D38D65E32C62E4B900AA140E /* LazyPager in Frameworks */ = {isa = PBXBuildFile; productRef = D38D65E22C62E4B900AA140E /* LazyPager */; }; D3B3AEAC2DBD500800AC1E33 /* EnvironmentExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B3AEAB2DBD500500AC1E33 /* EnvironmentExample.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ D353FB662A52174C00C04ABE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D353FB4D2A52174B00C04ABE /* Project object */; proxyType = 1; remoteGlobalIDString = D353FB542A52174B00C04ABE; remoteInfo = ImageScrollView; }; D353FB702A52174C00C04ABE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D353FB4D2A52174B00C04ABE /* Project object */; proxyType = 1; remoteGlobalIDString = D353FB542A52174B00C04ABE; remoteInfo = ImageScrollView; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ D309D37F2E88B66E007A6FDC /* InsetTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetTest.swift; sourceTree = ""; }; D33BC0512B69E6EE004B4338 /* SimpleExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleExample.swift; sourceTree = ""; }; D33F96FC2C62F582004D934A /* VerticalMediaPager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalMediaPager.swift; sourceTree = ""; }; D353FB552A52174B00C04ABE /* LazyPagerExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LazyPagerExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; D353FB582A52174B00C04ABE /* LazyPagerExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyPagerExampleApp.swift; sourceTree = ""; }; D353FB5A2A52174B00C04ABE /* FullTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTestView.swift; sourceTree = ""; }; D353FB5C2A52174C00C04ABE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D353FB5F2A52174C00C04ABE /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; D353FB652A52174C00C04ABE /* LazyPagerExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LazyPagerExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D353FB692A52174C00C04ABE /* LazyPagerExampleAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyPagerExampleAppTests.swift; sourceTree = ""; }; D353FB6F2A52174C00C04ABE /* LazyPagerExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LazyPagerExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D353FB732A52174C00C04ABE /* ImageScrollViewUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScrollViewUITests.swift; sourceTree = ""; }; D353FB752A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScrollViewUITestsLaunchTests.swift; sourceTree = ""; }; D3776B5E2CF5658500AFB89D /* AnimatedPagerControlsExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedPagerControlsExample.swift; sourceTree = ""; }; D38D65E02C62E47C00AA140E /* LazyPager */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = LazyPager; path = ..; sourceTree = ""; }; D3B3AEAB2DBD500500AC1E33 /* EnvironmentExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentExample.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ D353FB522A52174B00C04ABE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( D38D65E32C62E4B900AA140E /* LazyPager in Frameworks */, D367DA132A59E930004497D4 /* LazyPager in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; D353FB622A52174C00C04ABE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; D353FB6C2A52174C00C04ABE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ D353FB4C2A52174B00C04ABE = { isa = PBXGroup; children = ( D353FB572A52174B00C04ABE /* LazyPagerExampleApp */, D353FB682A52174C00C04ABE /* LazyPagerExampleAppTests */, D353FB722A52174C00C04ABE /* LazyPagerExampleAppUITests */, D353FB562A52174B00C04ABE /* Products */, D367DA112A59E930004497D4 /* Frameworks */, ); sourceTree = ""; }; D353FB562A52174B00C04ABE /* Products */ = { isa = PBXGroup; children = ( D353FB552A52174B00C04ABE /* LazyPagerExample.app */, D353FB652A52174C00C04ABE /* LazyPagerExampleTests.xctest */, D353FB6F2A52174C00C04ABE /* LazyPagerExampleUITests.xctest */, ); name = Products; sourceTree = ""; }; D353FB572A52174B00C04ABE /* LazyPagerExampleApp */ = { isa = PBXGroup; children = ( D3B3AEAB2DBD500500AC1E33 /* EnvironmentExample.swift */, D353FB582A52174B00C04ABE /* LazyPagerExampleApp.swift */, D33F96FC2C62F582004D934A /* VerticalMediaPager.swift */, D33BC0512B69E6EE004B4338 /* SimpleExample.swift */, D309D37F2E88B66E007A6FDC /* InsetTest.swift */, D3776B5E2CF5658500AFB89D /* AnimatedPagerControlsExample.swift */, D353FB5A2A52174B00C04ABE /* FullTestView.swift */, D353FB5C2A52174C00C04ABE /* Assets.xcassets */, D353FB5E2A52174C00C04ABE /* Preview Content */, ); path = LazyPagerExampleApp; sourceTree = ""; }; D353FB5E2A52174C00C04ABE /* Preview Content */ = { isa = PBXGroup; children = ( D353FB5F2A52174C00C04ABE /* Preview Assets.xcassets */, ); path = "Preview Content"; sourceTree = ""; }; D353FB682A52174C00C04ABE /* LazyPagerExampleAppTests */ = { isa = PBXGroup; children = ( D353FB692A52174C00C04ABE /* LazyPagerExampleAppTests.swift */, ); path = LazyPagerExampleAppTests; sourceTree = ""; }; D353FB722A52174C00C04ABE /* LazyPagerExampleAppUITests */ = { isa = PBXGroup; children = ( D353FB732A52174C00C04ABE /* ImageScrollViewUITests.swift */, D353FB752A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift */, ); path = LazyPagerExampleAppUITests; sourceTree = ""; }; D367DA112A59E930004497D4 /* Frameworks */ = { isa = PBXGroup; children = ( D38D65E02C62E47C00AA140E /* LazyPager */, ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ D353FB542A52174B00C04ABE /* LazyPagerExample */ = { isa = PBXNativeTarget; buildConfigurationList = D353FB792A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExample" */; buildPhases = ( D353FB512A52174B00C04ABE /* Sources */, D353FB522A52174B00C04ABE /* Frameworks */, D353FB532A52174B00C04ABE /* Resources */, ); buildRules = ( ); dependencies = ( ); name = LazyPagerExample; packageProductDependencies = ( D367DA122A59E930004497D4 /* LazyPager */, D38D65E22C62E4B900AA140E /* LazyPager */, ); productName = ImageScrollView; productReference = D353FB552A52174B00C04ABE /* LazyPagerExample.app */; productType = "com.apple.product-type.application"; }; D353FB642A52174C00C04ABE /* LazyPagerExampleTests */ = { isa = PBXNativeTarget; buildConfigurationList = D353FB7C2A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExampleTests" */; buildPhases = ( D353FB612A52174C00C04ABE /* Sources */, D353FB622A52174C00C04ABE /* Frameworks */, D353FB632A52174C00C04ABE /* Resources */, ); buildRules = ( ); dependencies = ( D353FB672A52174C00C04ABE /* PBXTargetDependency */, ); name = LazyPagerExampleTests; productName = ImageScrollViewTests; productReference = D353FB652A52174C00C04ABE /* LazyPagerExampleTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; D353FB6E2A52174C00C04ABE /* LazyPagerExampleUITests */ = { isa = PBXNativeTarget; buildConfigurationList = D353FB7F2A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExampleUITests" */; buildPhases = ( D353FB6B2A52174C00C04ABE /* Sources */, D353FB6C2A52174C00C04ABE /* Frameworks */, D353FB6D2A52174C00C04ABE /* Resources */, ); buildRules = ( ); dependencies = ( D353FB712A52174C00C04ABE /* PBXTargetDependency */, ); name = LazyPagerExampleUITests; productName = ImageScrollViewUITests; productReference = D353FB6F2A52174C00C04ABE /* LazyPagerExampleUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ D353FB4D2A52174B00C04ABE /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1420; LastUpgradeCheck = 1420; TargetAttributes = { D353FB542A52174B00C04ABE = { CreatedOnToolsVersion = 14.2; }; D353FB642A52174C00C04ABE = { CreatedOnToolsVersion = 14.2; TestTargetID = D353FB542A52174B00C04ABE; }; D353FB6E2A52174C00C04ABE = { CreatedOnToolsVersion = 14.2; TestTargetID = D353FB542A52174B00C04ABE; }; }; }; buildConfigurationList = D353FB502A52174B00C04ABE /* Build configuration list for PBXProject "LazyPagerExample" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = D353FB4C2A52174B00C04ABE; packageReferences = ( D38D65E12C62E4B900AA140E /* XCLocalSwiftPackageReference "../" */, ); productRefGroup = D353FB562A52174B00C04ABE /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( D353FB542A52174B00C04ABE /* LazyPagerExample */, D353FB642A52174C00C04ABE /* LazyPagerExampleTests */, D353FB6E2A52174C00C04ABE /* LazyPagerExampleUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ D353FB532A52174B00C04ABE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( D353FB602A52174C00C04ABE /* Preview Assets.xcassets in Resources */, D353FB5D2A52174C00C04ABE /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; D353FB632A52174C00C04ABE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; D353FB6D2A52174C00C04ABE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ D353FB512A52174B00C04ABE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( D353FB5B2A52174B00C04ABE /* FullTestView.swift in Sources */, D33F96FD2C62F582004D934A /* VerticalMediaPager.swift in Sources */, D3776B5F2CF5658500AFB89D /* AnimatedPagerControlsExample.swift in Sources */, D33BC0522B69E6EE004B4338 /* SimpleExample.swift in Sources */, D3B3AEAC2DBD500800AC1E33 /* EnvironmentExample.swift in Sources */, D353FB592A52174B00C04ABE /* LazyPagerExampleApp.swift in Sources */, D309D3802E88B66E007A6FDC /* InsetTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; D353FB612A52174C00C04ABE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( D353FB6A2A52174C00C04ABE /* LazyPagerExampleAppTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; D353FB6B2A52174C00C04ABE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( D353FB762A52174C00C04ABE /* ImageScrollViewUITestsLaunchTests.swift in Sources */, D353FB742A52174C00C04ABE /* ImageScrollViewUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ D353FB672A52174C00C04ABE /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D353FB542A52174B00C04ABE /* LazyPagerExample */; targetProxy = D353FB662A52174C00C04ABE /* PBXContainerItemProxy */; }; D353FB712A52174C00C04ABE /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D353FB542A52174B00C04ABE /* LazyPagerExample */; targetProxy = D353FB702A52174C00C04ABE /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ D353FB772A52174C00C04ABE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; D353FB782A52174C00C04ABE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; D353FB7A2A52174C00C04ABE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"LazyPagerExampleApp/Preview Content\""; DEVELOPMENT_TEAM = BX46265734; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollView; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; D353FB7B2A52174C00C04ABE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"LazyPagerExampleApp/Preview Content\""; DEVELOPMENT_TEAM = BX46265734; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollView; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; D353FB7D2A52174C00C04ABE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = BX46265734; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollViewTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LazyPagerExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/LazyPagerExample"; }; name = Debug; }; D353FB7E2A52174C00C04ABE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = BX46265734; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollViewTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LazyPagerExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/LazyPagerExample"; }; name = Release; }; D353FB802A52174C00C04ABE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = BX46265734; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollViewUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = ImageScrollView; }; name = Debug; }; D353FB812A52174C00C04ABE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = BX46265734; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dateit.ImageScrollViewUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = ImageScrollView; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ D353FB502A52174B00C04ABE /* Build configuration list for PBXProject "LazyPagerExample" */ = { isa = XCConfigurationList; buildConfigurations = ( D353FB772A52174C00C04ABE /* Debug */, D353FB782A52174C00C04ABE /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; D353FB792A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExample" */ = { isa = XCConfigurationList; buildConfigurations = ( D353FB7A2A52174C00C04ABE /* Debug */, D353FB7B2A52174C00C04ABE /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; D353FB7C2A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExampleTests" */ = { isa = XCConfigurationList; buildConfigurations = ( D353FB7D2A52174C00C04ABE /* Debug */, D353FB7E2A52174C00C04ABE /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; D353FB7F2A52174C00C04ABE /* Build configuration list for PBXNativeTarget "LazyPagerExampleUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( D353FB802A52174C00C04ABE /* Debug */, D353FB812A52174C00C04ABE /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ D38D65E12C62E4B900AA140E /* XCLocalSwiftPackageReference "../" */ = { isa = XCLocalSwiftPackageReference; relativePath = ../; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ D367DA122A59E930004497D4 /* LazyPager */ = { isa = XCSwiftPackageProductDependency; productName = LazyPager; }; D38D65E22C62E4B900AA140E /* LazyPager */ = { isa = XCSwiftPackageProductDependency; productName = LazyPager; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D353FB4D2A52174B00C04ABE /* Project object */; } ================================================ FILE: Examples/LazyPagerExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: Examples/LazyPagerExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: Examples/LazyPagerExampleApp/AnimatedPagerControlsExample.swift ================================================ import SwiftUI import LazyPager struct AnimatedPagerControlsExample: View { @State var data = [ "nora1", "nora2", "nora3", "nora4", "nora5", "nora6", ] @State var show = false @State var index = 0 var body: some View { VStack { LazyPager(data: data, page: $index) { element in Image(element) .resizable() .aspectRatio(contentMode: .fit) } HStack(spacing: 20) { Button("First") { withAnimation { index = 0 } } Button("Prev") { withAnimation { if index > 0 { index -= 1 } } } Button("Next") { withAnimation { if index < data.count { index += 1 } } } Button("Last") { withAnimation { index = data.count - 1 } } } } } } struct AnimatedPagerControlsExample_Previews: PreviewProvider { static var previews: some View { AnimatedPagerControlsExample() } } ================================================ FILE: Examples/LazyPagerExampleApp/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/Assets.xcassets/nora1.imageset/Contents.json ================================================ { "images" : [ { "filename" : "356181627_737281678149026_5519646735590788375_n.jpg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/Assets.xcassets/nora2.imageset/Contents.json ================================================ { "images" : [ { "filename" : "356184881_810974757010572_166165563303848404_n.jpg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/Assets.xcassets/nora3.imageset/Contents.json ================================================ { "images" : [ { "filename" : "356184996_1504506290292039_6439519590743317419_n.jpg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/Assets.xcassets/nora4.imageset/Contents.json ================================================ { "images" : [ { "filename" : "356187567_797832131883666_8693445044613773171_n.jpg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/Assets.xcassets/nora5.imageset/Contents.json ================================================ { "images" : [ { "filename" : "356198313_803291668248047_1588179413198578920_n.jpg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/Assets.xcassets/nora6.imageset/Contents.json ================================================ { "images" : [ { "filename" : "358743821_933760767702238_5920729387861732707_n.jpg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/EnvironmentExample.swift ================================================ // // EnvironmentExample.swift // LazyPagerExample // // Created by Brian Floersch on 4/26/25. // import SwiftUI import LazyPager struct SubView: View { @EnvironmentObject var textHolder: TextHolder @Environment(\.customValue) var customValue var parentText: String var body: some View { VStack { Text("\(textHolder.str) \(parentText)") .font(.title) .padding() Text("Environment value: \(customValue)") .font(.subheadline) .padding() } } } struct EnvironmentExample: View { @State var data = [ "nora1", "nora2", "nora3", "nora4", "nora5", "nora6", ] @State var show = false var body: some View { ZStack { LazyPager(data: data) { element in SubView(parentText: element) } } } } class TextHolder: ObservableObject { let str: String init(str: String) { self.str = str } } private struct CustomEnvironmentKey: EnvironmentKey { static let defaultValue: String = "default value" } extension EnvironmentValues { var customValue: String { get { self[CustomEnvironmentKey.self] } set { self[CustomEnvironmentKey.self] = newValue } } } #Preview { EnvironmentExample() .environmentObject(TextHolder(str: "hello world")) .environment(\.customValue, "custom environment value") } ================================================ FILE: Examples/LazyPagerExampleApp/FullTestView.swift ================================================ // // ContentView.swift // LazyPager // // Created by Brian Floersch on 7/2/23. // import SwiftUI import LazyPager struct Foo { let id = UUID() var img: String let idx: Int } struct FullTestView: View { var direction: Direction @State var data = [ Foo(img: "nora1", idx: 0), Foo(img: "nora2", idx: 1), Foo(img: "nora3", idx: 2), Foo(img: "nora4", idx: 3), Foo(img: "nora5", idx: 4), Foo(img: "nora6", idx: 5), Foo(img: "nora1", idx: 6), Foo(img: "nora2", idx: 7), Foo(img: "nora3", idx: 8), Foo(img: "nora4", idx: 9), Foo(img: "nora5", idx: 10), Foo(img: "nora6", idx: 11), ] @Binding var show: Bool @State var opacity: CGFloat = 1 @State var index = 0 @State var loadPager = false var body: some View { VStack { LazyPager(data: data, page: $index, direction: direction) { element in ZStack { Image(element.img) .resizable() .aspectRatio(contentMode: .fit) VStack { Text("\(index) \(element.idx) \(data.count - 1)") .foregroundColor(.black) .background(.white) } } } .zoomable(min: 1, max: 5) .onDismiss(backgroundOpacity: $opacity) { show = false } .onTap { print("tap") } .onDoubleTap { print("double tap") } .shouldLoadMore(on: .lastElement(minus: 2)) { data.append(Foo(img: "nora4", idx: data.count)) } .overscroll { position in if position == .beginning { print("Swiped past beginning") } else { print("Swiped past end") } } .onDrag { print("Drag") } .pageSpacing(10) .background(.black.opacity(opacity)) .background(ClearFullScreenBackground()) .ignoresSafeArea() VStack { HStack(spacing: 30) { Button("-") { index -= 1 } VStack(spacing: 10) { Button("append") { data.append(Foo(img: "nora4", idx: data.count + 1)) } Button("replace") { data[0] = Foo(img: "nora4", idx: data.count + 1) } Button("update") { data[0].img = "nora5" } } VStack(spacing: 10) { Button("del first") { data.remove(at: 0) index -= 1 } Button("del last") { data.remove(at: data.count - 1) } Button("jmp") { index = 10 } } Button("+") { index += 1 } } } .frame(maxWidth: .infinity) .background(.white) } } } struct FullTestView_Previews: PreviewProvider { static var previews: some View { FullTestView(direction: .horizontal, show: .constant(true)) } } ================================================ FILE: Examples/LazyPagerExampleApp/InsetTest.swift ================================================ import SwiftUI import LazyPager struct InsetTest: View { @State var data = [ "nora1", "nora2", "nora3", "nora4", "nora5", "nora6", ] @State var show = false @State var ignoreSafeArea = true var body: some View { NavigationStack { ZStack { if ignoreSafeArea { LazyPager(data: data) { element in Image(element) .resizable() .aspectRatio(contentMode: .fit) } .ignoresSafeArea() VStack { } .frame(width: 300, height: 300) .background(.red) .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() } else { LazyPager(data: data) { element in Image(element) .resizable() .aspectRatio(contentMode: .fit) } VStack { } .frame(width: 300, height: 300) .background(.red) .frame(maxWidth: .infinity, maxHeight: .infinity) } VStack { Spacer() Toggle("ignore safe area", isOn: $ignoreSafeArea) .padding() } } } } } #Preview { InsetTest() } ================================================ FILE: Examples/LazyPagerExampleApp/LazyPagerExampleApp.swift ================================================ // // LazyPagerApp.swift // LazyPager // // Created by Brian Floersch on 7/2/23. // import SwiftUI @main struct LazyPagerApp: App { @State var showFull = false var body: some Scene { WindowGroup { NavigationStack { VStack(spacing: 20) { NavigationLink(destination: SimpleExample()) { Text("Simple Example") } NavigationLink(destination: InsetTest()) { Text("Inset test") } NavigationLink(destination: EnvironmentExample() .environmentObject(TextHolder(str: "hello world")) .environment(\.customValue, "custom environment value") ) { Text("Environment Example") } NavigationLink(destination: AnimatedPagerControlsExample()) { Text("Animated Pager Controls Example") } Button("full Test View horizontal") { showFull.toggle() } NavigationLink(destination: FullTestView(direction: .vertical, show: .constant(true))) { Text("Full Test View vertical") } NavigationLink(destination: VerticalMediaPager()) { Text("Vertical media pager sample") } } } .fullScreenCover(isPresented: $showFull) { FullTestView(direction: .horizontal, show: $showFull) } } } } ================================================ FILE: Examples/LazyPagerExampleApp/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Examples/LazyPagerExampleApp/SimpleExample.swift ================================================ import SwiftUI import LazyPager struct SimpleExample: View { @State var data = [ "nora1", "nora2", "nora3", "nora4", "nora5", "nora6", ] @State var show = false var body: some View { LazyPager(data: data) { element in Image(element) .resizable() .aspectRatio(contentMode: .fit) } .onDismiss { } } } struct SimpleExample_Previews: PreviewProvider { static var previews: some View { SimpleExample() } } ================================================ FILE: Examples/LazyPagerExampleApp/VerticalMediaPager.swift ================================================ import SwiftUI import LazyPager struct VerticalMediaPager: View { @State var data = [ "nora1", "nora2", "nora3", "nora4", "nora5", "nora6", ] @State var show = true var body: some View { ZStack { LazyPager(data: data, direction: .vertical) { element in Image(element) .resizable() .aspectRatio(contentMode: .fill) } .overscroll { if $0 == .beginning { print("Swiped past beginning") } else if $0 == .end { print("Swiped past end") } } .ignoresSafeArea() VStack(alignment: .leading) { HStack { Spacer() VStack(spacing: 30) { Spacer() imgButton("heart.fill") imgButton("text.bubble.fill") imgButton("bookmark.fill") imgButton("arrow.turn.up.right") } .padding(.bottom, 20) } .padding() Spacer() HStack { VStack { Text("CatTok") .frame(maxWidth: .infinity, alignment: .leading) .font(.title2) Text("Nora is an adorable cat") .frame(maxWidth: .infinity, alignment: .leading) } Spacer() Text("😸") .font(.title) .padding(5) .background(.pink.opacity(0.8)) .clipShape(Circle()) } .padding() .foregroundColor(.white) .background(.black.opacity(0.5)) } } } @ViewBuilder func imgButton(_ name: String) -> some View { Image(systemName: name) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 40, height: 40) .foregroundColor(.white.opacity(0.9)) } } #Preview { VerticalMediaPager() } ================================================ FILE: Examples/LazyPagerExampleAppTests/LazyPagerExampleAppTests.swift ================================================ // // LazyPagerTests.swift // LazyPagerTests // // Created by Brian Floersch on 7/2/23. // import XCTest @testable import LazyPager final class LazyPagerTests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testExample() throws { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. // Any test you write for XCTest can be annotated as throws and async. // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. } func testPerformanceExample() throws { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } } ================================================ FILE: Examples/LazyPagerExampleAppUITests/ImageScrollViewUITests.swift ================================================ // // LazyPagerUITests.swift // LazyPagerUITests // // Created by Brian Floersch on 7/2/23. // import XCTest final class LazyPagerUITests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false // 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. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testExample() throws { // UI tests must launch the application that they test. let app = XCUIApplication() app.launch() // Use XCTAssert and related functions to verify your tests produce the correct results. } func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() } } } } ================================================ FILE: Examples/LazyPagerExampleAppUITests/ImageScrollViewUITestsLaunchTests.swift ================================================ // // LazyPagerUITestsLaunchTests.swift // LazyPagerUITests // // Created by Brian Floersch on 7/2/23. // import XCTest final class LazyPagerUITestsLaunchTests: XCTestCase { override class var runsForEachTargetApplicationUIConfiguration: Bool { true } override func setUpWithError() throws { continueAfterFailure = false } func testLaunch() throws { let app = XCUIApplication() app.launch() // Insert steps here to perform after app launch but before taking a screenshot, // such as logging into a test account or navigating somewhere in the app let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = "Launch Screen" attachment.lifetime = .keepAlways add(attachment) } } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 Brian Floersch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Package.swift ================================================ // swift-tools-version: 5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "LazyPager", platforms: [ .iOS(.v15) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "LazyPager", targets: ["LazyPager"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "LazyPager", dependencies: []), .testTarget( name: "LazyPagerTests", dependencies: ["LazyPager"]), ] ) ================================================ FILE: README.md ================================================ # LazyPager for SwiftUI A buttery smooth, lazy loaded, panning, zooming, and gesture dismissible view pager view for SwiftUI. 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. ### Horizontal

animated

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`. ### Vertical

animated

The above example [can be found in the example project.](https://github.com/gh123man/SwiftUI-LazyPager/blob/master/Examples/LazyPagerExampleApp/VerticalMediaPager.swift) # Usage ## Add the Swift Package 1. Right click on your project -> `Add Package` 2. In the search bar paste: `https://github.com/gh123man/LazyPager` 3. Click `Add Package` Or add the package to your `Package.swift` if your project is a Swift package. ## Examples ### Simple Example A simple image pager that displays images by name from your app assets. ```swift @State var data = [ ... ] var body: some View { LazyPager(data: data) { element in Image(element) .resizable() .aspectRatio(contentMode: .fit) } } ``` That's it! ### Detailed Example ```swift @State var data = [ ... ] @State var show = true @State var opacity: CGFloat = 1 // Dismiss gesture background opacity @State var index = 0 var body: some View { Button("Open") { show.toggle() } .fullScreenCover(isPresented: $show) { // Provide any list of data and bind to an index LazyPager(data: data, page: $index) { element in // Supports any kind of view - not only images Image(element) .resizable() .aspectRatio(contentMode: .fit) } // Make the content zoomable .zoomable(min: 1, max: 5) // Enable the swipe to dismiss gesture and background opacity control .onDismiss(backgroundOpacity: $opacity) { show = false } // Handle single tap gestures .onTap { print("tap") } // Get notified when to load more content .shouldLoadMore { data.append("foobar") } // Get notified when swiping past the beginning or end of the list .overscroll { position in if position == .beginning { print("Swiped past beginning") } else { print("Swiped past end") } } // Handle double tap gestures .onDoubleTap { print("double tap") } // Handle drag events initiated by the user .onDrag { print("Drag") } // Set the spacing between pages .pageSpacing(20) // Set the background color with the drag opacity control .background(.black.opacity(opacity)) // A special included modifier to help make fullScreenCover transparent .background(ClearFullScreenBackground()) // Works with safe areas or ignored safe areas .ignoresSafeArea() } } ``` #### Vertical paging ```swift @State var data = [ ... ] var body: some View { LazyPager(data: data, direction: .vertical) { element in Image(element) .resizable() .aspectRatio(contentMode: .fill) } } ``` 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) # Features - All content is lazy loaded. By default content is pre-loaded 3 elements ahead and behind the current index. - Display any kind of content - not just images! - Horizontal or Vertical paging. - Lazy loaded views are disposed when they are outside of the pre-load frame to conserve resources. - Enable zooming and panning with `.zoomable(min: CGFloat, max: CGFloat)`. - Double tap to zoom is also supported through `.zoomable` modifier. - Notifies when to load more content with `.shouldLoadMore`. - Notifies when you swipe past the beginning or end of data with `.overscroll`. - Animate page transitions by using `withAnimation` when changing the page index. - Works with `.ignoresSafeArea()` (or not) to get a true full screen view. - Drag to dismiss is supported with `.onDismiss` - Supply a binding opacity value to control the background opacity during the transition. - Tap events are handled internally, so use `.onTap` to handle single taps (useful for hiding and showing UI). - Use `.onDoubleTap` to get notified on double taps. - Use `.settings` to [modify advanced settings](https://github.com/gh123man/SwiftUI-LazyPager/blob/master/Sources/LazyPager/LazyPager.swift#L76). - Use `.absoluteContentPosition` to subscribe to content position updates (the index + the offset while paging) - Use `.onZoom` to get notified of the current zoom level - Use `.onDrag` to handle drag events when the user interacts with the view. No triggered when page is changed programmatically. - Use `.pageSpacing(CGFloat)` to set the spacing between pages. # Detailed usage ## Working with `fullScreenCover` `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. ## Double tap to zoom 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. ## Dismiss gesture handling 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. To control the dismiss opacity of a custom background, use a `Binding` like `.onDismiss(backgroundOpacity: $opacity) {` to fade out your custom background. ================================================ FILE: Sources/LazyPager/ClearFullScreenBackground.swift ================================================ // // ClearFullScreenBackground.swift // // // Created by Brian Floersch on 7/8/23. // import Foundation import SwiftUI import UIKit public struct ClearFullScreenBackground: UIViewRepresentable { public init() { } public func makeUIView(context: Context) -> UIView { let view = UIView() DispatchQueue.main.async { view.superview?.superview?.backgroundColor = .clear } return view } public func updateUIView(_ uiView: UIView, context: Context) {} } ================================================ FILE: Sources/LazyPager/Collection+Extensions.swift ================================================ // // Collection+Extensons.swift // // // Created by Brian Floersch on 7/8/23. // import Foundation extension Collection { /// Returns the element at the specified index if it is within bounds, otherwise nil. subscript (safe index: Index) -> Element? { return indices.contains(index) ? self[index] : nil } } ================================================ FILE: Sources/LazyPager/LazyPager.swift ================================================ // // LazyPager.swift // LazyPager // // Created by Brian Floersch on 7/6/23. // import Foundation import UIKit import SwiftUI public enum LoadMore { case lastElement(minus: Int = 0) } public enum DoubleTap { case disabled case scale(CGFloat) } public enum Direction { case horizontal case vertical } public enum ListPosition { case beginning case end } public enum ZoomConfig { case disabled case custom(min: CGFloat, max: CGFloat, doubleTap: DoubleTap) } public struct Config { /// binding variable to control a custom background opacity. LazyPager is transparent by default public var backgroundOpacity: Binding? /// Called when the view is done dismissing - dismiss gesture is disabled if nil public var dismissCallback: (() -> ())? /// Called when tapping once public var tapCallback: (() -> ())? /// Called when tapping twice public var doubleTapCallback: (() -> ())? /// Called when dragging begins public var dragCallback: (() -> ())? /// The offset used to trigger load loadMoreCallback public var loadMoreOn: LoadMore = .lastElement(minus: 3) /// Called when more content should be loaded public var loadMoreCallback: (() -> ())? /// Direction of the pager public var direction : Direction = .horizontal /// Called whent the end of data is reached and the user tries to swipe again public var overscrollCallback: ((ListPosition) -> ())? /// The element index + the offset while paging public var absoluteContentPosition: Binding? /// Called every view update to get the zoom config public var zoomConfigGetter: (Element) -> ZoomConfig = { _ in .disabled } /// Called while zooming to provide the current zoom level for an element public var onZoomHandler: ((Element, CGFloat) -> ())? /// The spacing between pages. Defaults to 0. public var pageSpacing: CGFloat = 0 /// Advanced settings (only accessible via .settings) /// How may out of view pages to load in advance (forward and backwards) public var preloadAmount: Int = 3 /// Minimum swipe velocity needed to trigger a dismiss public var dismissVelocity: CGFloat = 1.3 /// the minimum % (between 0 and 1) you need to drag to trigger a dismiss public var dismissTriggerOffset: CGFloat = 0.1 /// How long to animate the dismiss once done dragging public var dismissAnimationLength: CGFloat = 0.2 /// Cancel SwiftUI animations. Default to true because the dismiss gesture is already animated. /// Stacking animations can cause undesirable behavior public var shouldCancelSwiftUIAnimationsOnDismiss = true /// At what drag % (between 0 and 1) the background should be fully transparent public var fullFadeOnDragAt: CGFloat = 0.2 /// The minimum scroll distance the in which the pinch gesture is enabled public var pinchGestureEnableOffset: Double = 10 /// % ammount (from 0-1) of overscroll needed to call overscrollCallback public var overscrollThreshold: Double = 0.15 } public struct LazyPager where DataCollecton.Index == Int, DataCollecton.Element == Element { private var viewLoader: (Element) -> Content private var data: DataCollecton @State private var defaultPageInternal = 0 private var providedPage: Binding? private var page: Binding { providedPage ?? Binding( get: { defaultPageInternal }, set: { defaultPageInternal = $0 } ) } var config = Config() public init(data: DataCollecton, page: Binding? = nil, direction: Direction = .horizontal, @ViewBuilder content: @escaping (Element) -> Content) { self.data = data self.providedPage = page self.viewLoader = content self.config.direction = direction } } public extension LazyPager { func onDismiss(backgroundOpacity: Binding? = nil, _ callback: @escaping () -> ()) -> LazyPager { guard config.direction == .horizontal else { return self } var this = self this.config.backgroundOpacity = backgroundOpacity this.config.dismissCallback = callback return this } func onTap(_ callback: @escaping () -> ()) -> LazyPager { var this = self this.config.tapCallback = callback return this } func onDoubleTap(_ callback: @escaping () -> ()) -> LazyPager { var this = self this.config.doubleTapCallback = callback return this } func onDrag(_ callback: @escaping () -> ()) -> LazyPager { var this = self this.config.dragCallback = callback return this } func pageSpacing(_ spacing: CGFloat) -> LazyPager { var this = self this.config.pageSpacing = spacing return this } func shouldLoadMore(on: LoadMore = .lastElement(minus: 3), _ callback: @escaping () -> ()) -> LazyPager { var this = self this.config.loadMoreOn = on this.config.loadMoreCallback = callback return this } func zoomable(min: CGFloat, max: CGFloat, doubleTapGesture: DoubleTap = .scale(0.5)) -> LazyPager { var this = self this.config.zoomConfigGetter = { _ in return .custom(min: min, max: max, doubleTap: doubleTapGesture) } return this } func zoomable(onElement: @escaping (Element) -> ZoomConfig) -> LazyPager { var this = self this.config.zoomConfigGetter = onElement return this } func settings(_ adjust: @escaping (inout Config) -> ()) -> LazyPager { var this = self adjust(&this.config) return this } func overscroll(_ callback: @escaping (ListPosition) -> ()) -> LazyPager { var this = self this.config.overscrollCallback = callback return this } func absoluteContentPosition(_ absoluteContentPosition: Binding? = nil) -> LazyPager { guard config.direction == .horizontal else { return self } var this = self this.config.absoluteContentPosition = absoluteContentPosition return this } func onZoom(_ onZoomHandler: @escaping (Element, CGFloat) -> ()) -> LazyPager { var this = self this.config.onZoomHandler = onZoomHandler return this } } extension LazyPager: UIViewControllerRepresentable { public func makeUIViewController(context: Context) -> ViewDataProvider { let provider = ViewDataProvider(data: data, page: page, config: config, viewLoader: viewLoader) DispatchQueue.main.async { provider.goToPage(page.wrappedValue, animated: false) } return provider } public func updateUIViewController(_ uiViewController: ViewDataProvider, context: Context) { uiViewController.viewLoader = viewLoader uiViewController.data = data defer { uiViewController.reloadViews() } if page.wrappedValue != uiViewController.pagerView.currentIndex { // Index was explicitly updated uiViewController.goToPage(page.wrappedValue, animated: context.transaction.animation != nil) } if page.wrappedValue >= data.count { uiViewController.goToPage(data.count - 1, animated: false) } else if page.wrappedValue < 0 { uiViewController.goToPage(0, animated: false) } } } ================================================ FILE: Sources/LazyPager/Math.swift ================================================ // // Math.swift // // // Created by Brian Floersch on 7/8/23. // import Foundation func lerp(from: CGFloat, to: CGFloat, by: CGFloat) -> CGFloat { return from * (1 - by) + to * by } func normalize(from min: CGFloat, at val: CGFloat, to max: CGFloat) -> CGFloat { let v = (val - min) / (max - min) return v < 0 ? 0 : v > 1 ? 1 : v } ================================================ FILE: Sources/LazyPager/PagerView.swift ================================================ // // PagerView.swift // // // Created by Brian Floersch on 7/8/23. // import Foundation import UIKit import SwiftUI protocol ViewLoader: AnyObject { associatedtype Element associatedtype Content: View var dataCount: Int { get } func loadView(at: Int) -> ZoomableView? func updateHostedView(for zoomableView: ZoomableView) } class PagerView: UIScrollView, UIScrollViewDelegate where Loader.Element == Element, Loader.Content == Content { var pageSpacing: CGFloat { config.pageSpacing } var isFirstLoad = false var loadedViews = [ZoomableView]() var config: Config weak var viewLoader: Loader? var lastBoundsSize: CGSize? var isRotating = false var page: Binding var currentIndex: Int = 0 { didSet { loadMoreIfNeeded() } } var absoluteOffset: CGFloat { var absoluteOffset: CGFloat if config.direction == .horizontal { absoluteOffset = self.contentOffset.x / self.pageWidth } else { absoluteOffset = self.contentOffset.y / self.pageHeight } return absoluteOffset } var relativeIndex: Int { if absoluteOffset.isInfinite || absoluteOffset.isNaN { return 0 } var idx = Int(round(absoluteOffset)) idx = idx < 0 ? 0 : idx idx = idx >= loadedViews.count ? loadedViews.count-1 : idx return idx } var currentView: ZoomableView { loadedViews[relativeIndex] } var pageWidth: CGFloat { if config.direction == .horizontal { return frame.width + pageSpacing } return frame.width } var pageHeight: CGFloat { if config.direction == .vertical { return frame.height + pageSpacing } return frame.height } init(page: Binding, config: Config) { self.currentIndex = page.wrappedValue self.page = page self.config = config super.init(frame: .zero) showsVerticalScrollIndicator = false showsHorizontalScrollIndicator = false backgroundColor = .clear decelerationRate = .fast delegate = self contentInsetAdjustmentBehavior = .always // DEBUG // backgroundColor = .blue } required init?(coder: NSCoder) { fatalError("Not implemented") } public override func layoutSubviews() { super.layoutSubviews() if !isFirstLoad { ensureCurrentPage(animated: false) isFirstLoad = true } else if isRotating { ensureCurrentPage(animated: false) } // Ensures insets are updated when the screen rotates if bounds.size != lastBoundsSize { updateInsets(animated: lastBoundsSize != nil) lastBoundsSize = bounds.size } } func computeViewState(immediate: Bool = false) { delegate = nil DispatchQueue.main.async { self.delegate = self } if subviews.isEmpty { for i in currentIndex...(currentIndex + config.preloadAmount) { if immediate { appendView(at: i) } else { scheduleAppend(at: i) } } for i in ((currentIndex - config.preloadAmount)..) { super.addSubview(zoomView) NSLayoutConstraint.activate([ zoomView.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor), zoomView.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor), ]) } func addFirstView(_ zoomView: ZoomableView) { if config.direction == .horizontal { zoomView.leadingConstraint = zoomView.leadingAnchor.constraint(equalTo: leadingAnchor) zoomView.trailingConstraint = zoomView.trailingAnchor.constraint(equalTo: trailingAnchor) zoomView.leadingConstraint?.isActive = true zoomView.trailingConstraint?.isActive = true } else { zoomView.topConstraint = zoomView.topAnchor.constraint(equalTo: topAnchor) zoomView.bottomConstraint = zoomView.bottomAnchor.constraint(equalTo: bottomAnchor) zoomView.topConstraint?.isActive = true zoomView.bottomConstraint?.isActive = true } } func scheduleAppend(at index: Int) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } // Ensure we are not trying to add a view that has already been loaded if self.loadedViews.contains(where: { $0.index == index }) { return } self.appendView(at: index) } } func appendView(at index: Int) { guard let zoomView = viewLoader?.loadView(at: index) else { return } addSubview(zoomView) if let lastView = loadedViews.last { if config.direction == .horizontal { lastView.trailingConstraint?.isActive = false lastView.trailingConstraint = nil zoomView.leadingConstraint = zoomView.leadingAnchor.constraint(equalTo: lastView.trailingAnchor, constant: pageSpacing) zoomView.trailingConstraint = zoomView.trailingAnchor.constraint(equalTo: trailingAnchor) zoomView.leadingConstraint?.isActive = true zoomView.trailingConstraint?.isActive = true } else { lastView.bottomConstraint?.isActive = false lastView.bottomConstraint = nil zoomView.topConstraint = zoomView.topAnchor.constraint(equalTo: lastView.bottomAnchor, constant: pageSpacing) zoomView.bottomConstraint = zoomView.bottomAnchor.constraint(equalTo: bottomAnchor) zoomView.topConstraint?.isActive = true zoomView.bottomConstraint?.isActive = true } } else { addFirstView(zoomView) } loadedViews.append(zoomView) } func schedulePrepend(at index: Int) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } // Ensure we are not trying to add a view that has already been loaded if self.loadedViews.contains(where: { $0.index == index }) { return } self.prependView(at: index) } } func prependView(at index: Int) { guard let zoomView = viewLoader?.loadView(at: index) else { return } addSubview(zoomView) if let firstView = loadedViews.first { if config.direction == .horizontal { firstView.leadingConstraint?.isActive = false firstView.leadingConstraint = nil zoomView.leadingConstraint = zoomView.leadingAnchor.constraint(equalTo: leadingAnchor) zoomView.trailingConstraint = zoomView.trailingAnchor.constraint(equalTo: firstView.leadingAnchor, constant: -pageSpacing) zoomView.leadingConstraint?.isActive = true zoomView.trailingConstraint?.isActive = true } else { firstView.topConstraint?.isActive = false firstView.topConstraint = nil zoomView.topConstraint = zoomView.topAnchor.constraint(equalTo: topAnchor) zoomView.bottomConstraint = zoomView.bottomAnchor.constraint(equalTo: firstView.topAnchor, constant: -pageSpacing) zoomView.topConstraint?.isActive = true zoomView.bottomConstraint?.isActive = true } } else { addFirstView(zoomView) } loadedViews.insert(zoomView, at: 0) if config.direction == .horizontal { contentOffset.x += pageWidth } else { contentOffset.y += pageHeight } } func reloadViews() { for view in loadedViews { viewLoader?.updateHostedView(for: view) } } func remove(view: ZoomableView) { guard let viewIndex = loadedViews.firstIndex(where: { $0.index == view.index }) else { return } let viewToDisconnect = loadedViews[viewIndex] let prevView: ZoomableView? = loadedViews[safe: viewIndex - 1] let nextView: ZoomableView? = loadedViews[safe: viewIndex + 1] let removedIndex = viewToDisconnect.index viewToDisconnect.removeFromSuperview() loadedViews.remove(at: viewIndex) if config.direction == .horizontal { if let prevView = prevView, let nextView = nextView { // Both exist, removing from the middle prevView.trailingConstraint?.isActive = false prevView.trailingConstraint = prevView.trailingAnchor.constraint(equalTo: nextView.leadingAnchor, constant: -pageSpacing) prevView.trailingConstraint?.isActive = true } else if let prevView = prevView { // This was the last view prevView.trailingConstraint?.isActive = false prevView.trailingConstraint = prevView.trailingAnchor.constraint(equalTo: trailingAnchor) prevView.trailingConstraint?.isActive = true } else if let nextView = nextView { // This was the first view nextView.leadingConstraint?.isActive = false nextView.leadingConstraint = nextView.leadingAnchor.constraint(equalTo: leadingAnchor) nextView.leadingConstraint?.isActive = true } if removedIndex < (loadedViews.first?.index ?? 0) { contentOffset.x -= pageWidth } } else { if let prevView = prevView, let nextView = nextView { // Both exist, removing from the middle prevView.bottomConstraint?.isActive = false prevView.bottomConstraint = prevView.bottomAnchor.constraint(equalTo: nextView.topAnchor, constant: -pageSpacing) prevView.bottomConstraint?.isActive = true } else if let prevView = prevView { // This was the last view prevView.bottomConstraint?.isActive = false prevView.bottomConstraint = prevView.bottomAnchor.constraint(equalTo: bottomAnchor) prevView.bottomConstraint?.isActive = true } else if let nextView = nextView { // This was the first view nextView.topConstraint?.isActive = false nextView.topConstraint = nextView.topAnchor.constraint(equalTo: topAnchor) nextView.topConstraint?.isActive = true } if removedIndex < (loadedViews.first?.index ?? 0) { contentOffset.y -= pageHeight } } } func removeOutOfFrameViews() { guard let viewLoader = viewLoader else { return } for view in loadedViews { if abs(currentIndex - view.index) > config.preloadAmount || view.index >= viewLoader.dataCount { remove(view: view) } } } func resizeOutOfBoundsViews() { for v in loadedViews { if v.index != currentIndex { v.zoomScale = 1 } } } func goToPage(_ page: Int, animated: Bool) { currentIndex = page DispatchQueue.main.async { self.computeViewState(immediate: true) self.ensureCurrentPage(animated: animated) } } func ensureCurrentPage(animated: Bool) { guard let index = loadedViews.firstIndex(where: { $0.index == currentIndex }) else { return } if config.direction == .horizontal { setContentOffset(CGPoint(x: CGFloat(index) * pageWidth, y: contentOffset.y), animated: animated) } else { setContentOffset(CGPoint(x: contentOffset.x, y: CGFloat(index) * pageHeight), animated: animated) } self.currentView.dismissEnabled = true } func loadMoreIfNeeded() { guard let loadMoreCallback = config.loadMoreCallback else { return } guard case let .lastElement(offset) = config.loadMoreOn else { return } guard let viewLoader = viewLoader else { return } if currentIndex + offset >= viewLoader.dataCount - 1 { DispatchQueue.main.async { loadMoreCallback() } } } func scrollingFinished() { let newIndex = currentView.index if currentIndex != newIndex { currentIndex = newIndex page.wrappedValue = newIndex } computeViewState() hasNotfiedOverscroll = false resizeOutOfBoundsViews() if loadedViews.isEmpty { return } currentView.dismissEnabled = true } // MARK: UISCrollVieDelegate methods var lastPos: CGFloat = 0 var hasNotfiedOverscroll = false func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { config.dragCallback?() } public func scrollViewDidScroll(_ scrollView: UIScrollView) { if !scrollView.isTracking, !isRotating, (currentView.index != page.wrappedValue || page.wrappedValue != currentIndex ) { currentIndex = currentView.index page.wrappedValue = currentIndex } if let index = loadedViews[safe: relativeIndex]?.index { config.absoluteContentPosition?.wrappedValue = CGFloat(index) + absoluteOffset - CGFloat(relativeIndex) } if !hasNotfiedOverscroll { if relativeIndex >= loadedViews.count-1, absoluteOffset - CGFloat(relativeIndex) > config.overscrollThreshold { config.overscrollCallback?(.end) hasNotfiedOverscroll = true } if relativeIndex <= 0, absoluteOffset - CGFloat(relativeIndex) < -config.overscrollThreshold { config.overscrollCallback?(.beginning) hasNotfiedOverscroll = true } } if loadedViews.isEmpty { return } self.currentView.dismissEnabled = false } public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { let targetPage: CGFloat let velocityThreshold: CGFloat = 0.5 // a value to tune if config.direction == .horizontal { let currentRelativePage = scrollView.contentOffset.x / pageWidth if velocity.x > velocityThreshold { // Swiped forward targetPage = floor(currentRelativePage + 1) } else if velocity.x < -velocityThreshold { // Swiped backward targetPage = ceil(currentRelativePage - 1) } else { // No strong swipe, snap to nearest targetPage = round(currentRelativePage) } targetContentOffset.pointee.x = targetPage * pageWidth } else { let currentRelativePage = scrollView.contentOffset.y / pageHeight if velocity.y > velocityThreshold { targetPage = floor(currentRelativePage + 1) } else if velocity.y < -velocityThreshold { targetPage = ceil(currentRelativePage - 1) } else { targetPage = round(currentRelativePage) } targetContentOffset.pointee.y = targetPage * pageHeight } } public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { scrollingFinished() } } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollingFinished() } } ================================================ FILE: Sources/LazyPager/ViewDataProvider.swift ================================================ // // ViewDataProvider.swift // // // Created by Brian Floersch on 7/8/23. // import Foundation import SwiftUI import UIKit public class ViewDataProvider: UIViewController, ViewLoader where DataCollecton.Index == Int, DataCollecton.Element == Element { var viewLoader: (Element) -> Content var data: DataCollecton var config: Config var pagerView: PagerView var dataCount: Int { return data.count } init(data: DataCollecton, page: Binding, config: Config, viewLoader: @escaping (Element) -> Content) { self.data = data self.viewLoader = viewLoader self.config = config self.pagerView = PagerView(page: page, config: config) super.init(nibName: nil, bundle: nil) self.pagerView.viewLoader = self pagerView.computeViewState() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func goToPage(_ page: Int, animated: Bool) { pagerView.goToPage(page, animated: animated) } func reloadViews() { pagerView.reloadViews() pagerView.computeViewState() } // MARK: ViewLoader func loadView(at index: Int) -> ZoomableView? { guard let dta = data[safe: index] else { return nil } let hostingController = UIHostingController(rootView: viewLoader(dta)) return ZoomableView(hostingController: hostingController, index: index, data: dta, config: config) } func updateHostedView(for zoomableView: ZoomableView) { guard let dta = data[safe: zoomableView.index] else { return } zoomableView.hostingController.rootView = viewLoader(dta) } // MARK: UIViewController public override func loadView() { self.view = pagerView } public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) pagerView.isRotating = true self.pagerView.scrollingFinished() coordinator.animate(alongsideTransition: { context in }, completion: { context in self.pagerView.isRotating = false DispatchQueue.main.async { self.pagerView.goToPage(self.pagerView.currentIndex, animated: false) } }) } } ================================================ FILE: Sources/LazyPager/ZoomableView.swift ================================================ // // ZoomableView.swift // LazyPager // // Created by Brian Floersch on 7/4/23. // import Foundation import UIKit import SwiftUI class ZoomableView: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate { var trailingConstraint: NSLayoutConstraint? var leadingConstraint: NSLayoutConstraint? var topConstraint: NSLayoutConstraint? var bottomConstraint: NSLayoutConstraint? var contentTopToContent: NSLayoutConstraint! var contentTopToFrame: NSLayoutConstraint! var contentBottomToFrame: NSLayoutConstraint! var contentBottomToView: NSLayoutConstraint! var config: Config var bottomView: UIView var allowScroll: Bool = true { didSet { if allowScroll, config.direction == .horizontal { contentTopToFrame.isActive = false contentBottomToFrame.isActive = false bottomView.isHidden = false contentTopToContent.isActive = true contentBottomToView.isActive = true } else { contentTopToContent.isActive = false contentBottomToView.isActive = false contentTopToFrame.isActive = true contentBottomToFrame.isActive = true bottomView.isHidden = true } } } var wasTracking = false var isZoomHappening = false var dismissEnabled = false // Contorlled by PagerView to prevent flicker var lastInset: CGFloat = 0 var currentZoomInsetAnimation: UIViewPropertyAnimator? var hostingController: UIHostingController var index: Int var data: Element var doubleTap: DoubleTap? var lastBoundsSize: CGSize? var view: UIView { return hostingController.view } init(hostingController: UIHostingController, index: Int, data: Element, config: Config) { self.index = index self.hostingController = hostingController self.data = data self.config = config let v = UIView() self.bottomView = v super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false delegate = self panGestureRecognizer.delegate = self updateZoomConfig() bouncesZoom = true backgroundColor = .clear alwaysBounceVertical = false contentInsetAdjustmentBehavior = .never if config.dismissCallback != nil { alwaysBounceVertical = true } showsVerticalScrollIndicator = false showsHorizontalScrollIndicator = false view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .clear decelerationRate = .fast // DEBUG // backgroundColor = .red addSubview(view) NSLayoutConstraint.activate([ view.leadingAnchor.constraint(equalTo: leadingAnchor), view.trailingAnchor.constraint(equalTo: trailingAnchor), view.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor), view.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor), ]) contentTopToFrame = view.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor) contentTopToContent = view.topAnchor.constraint(equalTo: topAnchor) contentBottomToFrame = view.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor) contentBottomToView = view.bottomAnchor.constraint(equalTo: bottomView.topAnchor) v.translatesAutoresizingMaskIntoConstraints = false addSubview(v) // This is for future support of a drawer view let constant: CGFloat = config.dismissCallback == nil ? 0 : 1 NSLayoutConstraint.activate([ v.bottomAnchor.constraint(equalTo: bottomAnchor), v.leadingAnchor.constraint(equalTo: frameLayoutGuide.leadingAnchor), v.trailingAnchor.constraint(equalTo: frameLayoutGuide.trailingAnchor), v.heightAnchor.constraint(equalToConstant: constant) ]) var singleTapGesture: UITapGestureRecognizer? if config.tapCallback != nil { let gesture = UITapGestureRecognizer(target: self, action: #selector(singleTap(_:))) gesture.numberOfTapsRequired = 1 gesture.numberOfTouchesRequired = 1 addGestureRecognizer(gesture) singleTapGesture = gesture } func setupDoubleTapGesture() { let doubleTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(onDoubleTap(_:))) doubleTapRecognizer.numberOfTapsRequired = 2 doubleTapRecognizer.numberOfTouchesRequired = 1 addGestureRecognizer(doubleTapRecognizer) singleTapGesture?.require(toFail: doubleTapRecognizer) } if case .scale = doubleTap { setupDoubleTapGesture() } else if config.doubleTapCallback != nil { setupDoubleTapGesture() } DispatchQueue.main.async { self.updateState() } } required init?(coder: NSCoder) { fatalError("Not implemented") } func updateZoomConfig() { switch config.zoomConfigGetter(data) { case .disabled: maximumZoomScale = 1 minimumZoomScale = 1 doubleTap = nil case let .custom(min, max, doubleTap): minimumZoomScale = min maximumZoomScale = max self.doubleTap = doubleTap } } @objc func singleTap(_ recognizer: UITapGestureRecognizer) { config.tapCallback?() } @objc func onDoubleTap(_ recognizer: UITapGestureRecognizer) { config.doubleTapCallback?() if case let .scale(scale) = doubleTap { let pointInView = recognizer.location(in: view) zoom(at: pointInView, scale: scale) updateInsets() } } func updateState() { updateZoomConfig() allowScroll = zoomScale == 1 if contentOffset.y > config.pinchGestureEnableOffset, allowScroll { pinchGestureRecognizer?.isEnabled = false } else { pinchGestureRecognizer?.isEnabled = true } if allowScroll { if dismissEnabled, config.dismissCallback != nil { let offset = contentOffset.y if offset < 0 { let absoluteDragOffset = normalize(from: 0, at: abs(offset), to: frame.size.height) let fadeOffset = normalize(from: 0, at: absoluteDragOffset, to: config.fullFadeOnDragAt) config.backgroundOpacity?.wrappedValue = 1 - fadeOffset } else { DispatchQueue.main.async { self.config.backgroundOpacity?.wrappedValue = 1 } } } wasTracking = isTracking } } func zoom(at point: CGPoint, scale: CGFloat) { let mid = lerp(from: minimumZoomScale, to: maximumZoomScale, by: scale) let newZoomScale = zoomScale == minimumZoomScale ? mid : minimumZoomScale let size = bounds.size let w = size.width / newZoomScale let h = size.height / newZoomScale let x = point.x - (w * 0.5) let y = point.y - (h * 0.5) zoom(to: CGRect(x: x, y: y, width: w, height: h), animated: true) } override func layoutSubviews() { super.layoutSubviews() // Ensures insets are updated when the screen rotates if bounds.size != lastBoundsSize { lastBoundsSize = bounds.size updateInsets() } } // MARK: UIScrollViewDelegate methods func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { isZoomHappening = true updateState() } func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { isZoomHappening = false updateState() updateInsets() } func scrollViewDidScroll(_ scrollView: UIScrollView) { updateState() } func viewForZooming(in scrollView: UIScrollView) -> UIView? { return view } func updateInsets() { let w: CGFloat = view.intrinsicContentSize.width * UIScreen.main.scale let h: CGFloat = view.intrinsicContentSize.height * UIScreen.main.scale let ratioW = view.frame.width / w let ratioH = view.frame.height / h let ratio = ratioW < ratioH ? ratioW : ratioH let newWidth = w*ratio let newHeight = h*ratio let left = 0.5 * (newWidth * zoomScale > view.frame.width ? (newWidth - view.frame.width) : (frame.width - view.frame.width)) let top = 0.5 * (newHeight * zoomScale > view.frame.height ? (newHeight - view.frame.height) : (frame.height - view.frame.height)) if zoomScale <= maximumZoomScale { let targetInsets = UIEdgeInsets( top: top, left: left, bottom: top, right: left ) UIView.animate(withDuration: 0.3) { self.contentInset = targetInsets } } } func scrollViewDidZoom(_ scrollView: UIScrollView) { let scrollViewSize = scrollView.bounds.size let zoomViewSize = view.frame.size let horizontalInset = max(0, (scrollViewSize.width - zoomViewSize.width) / 2) let verticalInset = max(0, (scrollViewSize.height - zoomViewSize.height) / 2) scrollView.contentInset = UIEdgeInsets(top: verticalInset, left: horizontalInset, bottom: verticalInset, right: horizontalInset) config.onZoomHandler?(data, scrollView.zoomScale) } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { let percentage = contentOffset.y / (contentSize.height - bounds.size.height) if wasTracking, percentage < -config.dismissTriggerOffset, !isZoomHappening, velocity.y < -config.dismissVelocity, config.dismissCallback != nil { dismissEnabled = false // prevent touch interaction from messing with animation of opacity. let ogFram = frame.origin withAnimation(.linear(duration: self.config.dismissAnimationLength)) { self.config.backgroundOpacity?.wrappedValue = 0 } frame.origin.y = -contentOffset.y UIView.animate(withDuration: self.config.dismissAnimationLength, animations: { self.frame.origin = CGPoint(x: ogFram.x, y: self.frame.size.height) }) { _ in if self.config.shouldCancelSwiftUIAnimationsOnDismiss { var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { self.config.dismissCallback?() } } else { self.config.dismissCallback?() } } } } // MARK: UIGestureRecognizerDelegate override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { // We only want to intercept our own pan gesture. guard gestureRecognizer == self.panGestureRecognizer else { return true } let panGesture = self.panGestureRecognizer let velocity = panGesture.velocity(in: self) // This logic is for the horizontal pager. if config.direction == .horizontal { // If the swipe is mostly vertical, it's for dismissal. Let it happen. if abs(velocity.y) > abs(velocity.x) { return true } // It's a horizontal swipe. Should we let our own pan gesture begin? // If not zoomed, NO. The pager should handle all horizontal movement. if zoomScale <= minimumZoomScale { return false // Prevent our pan, let PagerView handle it. } // If we ARE zoomed, check if we're at the horizontal edges. let maxOffsetX = contentSize.width - bounds.width + contentInset.right let minOffsetX = -contentInset.left let isAtRightEdge = contentOffset.x >= maxOffsetX - 1.0 let isAtLeftEdge = contentOffset.x <= minOffsetX + 1.0 // At the right edge and trying to swipe left (to the next page). if isAtRightEdge && velocity.x < 0 { return false // Prevent our pan, let PagerView handle it. } // At the left edge and trying to swipe right (to the previous page). if isAtLeftEdge && velocity.x > 0 { return false // Prevent our pan, let PagerView handle it. } } else { // Vertical Pager // If the swipe is mostly horizontal, let it happen. if abs(velocity.x) > abs(velocity.y) { return true } // It's a vertical swipe. Should we let our own pan gesture begin? // If not zoomed, NO. The pager should handle all vertical movement. if zoomScale <= minimumZoomScale { return false // Prevent our pan, let PagerView handle it. } // If we ARE zoomed, check if we're at the vertical edges. let maxOffsetY = contentSize.height - bounds.height + contentInset.bottom let minOffsetY = -contentInset.top let isAtBottomEdge = contentOffset.y >= maxOffsetY - 1.0 let isAtTopEdge = contentOffset.y <= minOffsetY + 1.0 // At the bottom edge and trying to swipe up (to the next page). if isAtBottomEdge && velocity.y < 0 { return false // Prevent our pan, let PagerView handle it. } // At the top edge and trying to swipe down (to the previous page). if isAtTopEdge && velocity.y > 0 { return false // Prevent our pan, let PagerView handle it. } } // If we're not at an edge while zoomed, our pan gesture should begin. return true } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { // We no longer need simultaneous recognition. This simplifies the logic and // prevents the differential panning issues. return false } } ================================================ FILE: Tests/LazyPagerTests/LazyPagerTests.swift ================================================ import XCTest @testable import LazyPager final class LazyPagerTests: XCTestCase { func testExample() throws { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct // results. // XCTAssertEqual(LazyPager().text, "Hello, World!") } }