├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TOPagingView.podspec ├── TOPagingView.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── TOPagingViewExample.xcscheme ├── TOPagingView ├── TOPagingView.h └── TOPagingView.m ├── TOPagingViewExample ├── Base.lproj │ └── LaunchScreen.storyboard ├── Info.plist ├── TOAppDelegate.h ├── TOAppDelegate.m ├── TOTestPageView.h ├── TOTestPageView.m ├── TOViewController.h ├── TOViewController.m └── main.m └── TOPagingViewTests ├── Info.plist └── TODynamicPageViewTests.m /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | # CocoaPods 32 | # 33 | # We recommend against adding the Pods directory to your .gitignore. However 34 | # you should judge for yourself, the pros and cons are mentioned at: 35 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 36 | # 37 | # Pods/ 38 | 39 | # Carthage 40 | # 41 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 42 | # Carthage/Checkouts 43 | 44 | Carthage/Build 45 | 46 | # fastlane 47 | # 48 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 49 | # screenshots whenever they are needed. 50 | # For more information about the recommended setup visit: 51 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 52 | 53 | fastlane/report.xml 54 | fastlane/Preview.html 55 | fastlane/screenshots/**/*.png 56 | fastlane/test_output 57 | 58 | # Code Injection 59 | # 60 | # After new code Injection tools there's a generated folder /iOSInjectionProject 61 | # https://github.com/johnno1962/injectionforxcode 62 | 63 | iOSInjectionProject/ 64 | /RevealServer.framework 65 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | x.y.z Release Notes (yyyy-MM-dd) 2 | ============================================================= 3 | 4 | 1.2.0 Release Notes (2023-10-23) 5 | ============================================================= 6 | 7 | ## Changes 8 | 9 | * For greater efficiency, all of the data source methods are combined into one. 10 | * More performance improvements. 11 | 12 | 13 | 1.1.0 Release Notes (2022-02-04) 14 | ============================================================= 15 | 16 | ## Fixed 17 | 18 | * Keyboard controls not working in iOS 15. 19 | 20 | ## Enhancements 21 | 22 | * Streamlined name of library to `TOPagingView`. 23 | 24 | 1.0.1 Release Notes (2020-04-19) 25 | ============================================================= 26 | 27 | ## Fixed 28 | 29 | * A bug where page layout wouldn't align when the device was rotated. 30 | 31 | 1.0.0 Release Notes (2020-04-19) 32 | ============================================================= 33 | 34 | * Initial Release! 🎉 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2020 Tim Oliver 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TOPagingView 2 | A paging scroll view that can handle arbitrary numbers of page views at run-time. 3 | -------------------------------------------------------------------------------- /TOPagingView.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'TOPagingView' 3 | s.version = '1.2.0' 4 | s.license = { :type => 'MIT', :file => 'LICENSE' } 5 | s.summary = 'A paging scroll view that can handle arbitrary numbers of page views at runtime.' 6 | s.homepage = 'https://github.com/TimOliver/TOPagingView' 7 | s.author = 'Tim Oliver' 8 | s.source = { :git => 'https://github.com/TimOliver/TOPagingView.git', :tag => s.version } 9 | s.platform = :ios, '12.0' 10 | s.source_files = 'TOPagingView/**/*.{h,m}' 11 | s.requires_arc = true 12 | end 13 | -------------------------------------------------------------------------------- /TOPagingView.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXAggregateTarget section */ 10 | 22AD8C13284B25C6009F6265 /* OCLint */ = { 11 | isa = PBXAggregateTarget; 12 | buildConfigurationList = 22AD8C16284B25C6009F6265 /* Build configuration list for PBXAggregateTarget "OCLint" */; 13 | buildPhases = ( 14 | 22AD8C17284B25DA009F6265 /* ShellScript */, 15 | ); 16 | dependencies = ( 17 | ); 18 | name = OCLint; 19 | productName = OCLint; 20 | }; 21 | /* End PBXAggregateTarget section */ 22 | 23 | /* Begin PBXBuildFile section */ 24 | 227BEDFA27AC1867009D6B6D /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = 227BEDF927AC1867009D6B6D /* CHANGELOG.md */; }; 25 | 22ADED24242B2ADD004A7854 /* TOTestPageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 22ADED23242B2ADD004A7854 /* TOTestPageView.m */; }; 26 | 22C31620242899AA0063F6A6 /* TOAppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 22C3161F242899AA0063F6A6 /* TOAppDelegate.m */; }; 27 | 22C31626242899AA0063F6A6 /* TOViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 22C31625242899AA0063F6A6 /* TOViewController.m */; }; 28 | 22C3162E242899AB0063F6A6 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 22C3162C242899AB0063F6A6 /* LaunchScreen.storyboard */; }; 29 | 22C31631242899AB0063F6A6 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 22C31630242899AB0063F6A6 /* main.m */; }; 30 | 22C3163B242899AB0063F6A6 /* TODynamicPageViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 22C3163A242899AB0063F6A6 /* TODynamicPageViewTests.m */; }; 31 | 22C3167F242A40F80063F6A6 /* TOPagingView.m in Sources */ = {isa = PBXBuildFile; fileRef = 22C3167E242A40F80063F6A6 /* TOPagingView.m */; }; 32 | /* End PBXBuildFile section */ 33 | 34 | /* Begin PBXContainerItemProxy section */ 35 | 22C31637242899AB0063F6A6 /* PBXContainerItemProxy */ = { 36 | isa = PBXContainerItemProxy; 37 | containerPortal = 22C31613242899AA0063F6A6 /* Project object */; 38 | proxyType = 1; 39 | remoteGlobalIDString = 22C3161A242899AA0063F6A6; 40 | remoteInfo = TODynamicPageView; 41 | }; 42 | /* End PBXContainerItemProxy section */ 43 | 44 | /* Begin PBXFileReference section */ 45 | 227BEDF927AC1867009D6B6D /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 46 | 227BEDFB27AC186F009D6B6D /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 47 | 227BEDFC27AC1879009D6B6D /* TOPagingView.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = TOPagingView.podspec; sourceTree = ""; }; 48 | 22ADED22242B2ADD004A7854 /* TOTestPageView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TOTestPageView.h; sourceTree = ""; }; 49 | 22ADED23242B2ADD004A7854 /* TOTestPageView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TOTestPageView.m; sourceTree = ""; }; 50 | 22C3161B242899AA0063F6A6 /* TOPagingViewExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TOPagingViewExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 51 | 22C3161E242899AA0063F6A6 /* TOAppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TOAppDelegate.h; sourceTree = ""; }; 52 | 22C3161F242899AA0063F6A6 /* TOAppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TOAppDelegate.m; sourceTree = ""; }; 53 | 22C31624242899AA0063F6A6 /* TOViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TOViewController.h; sourceTree = ""; }; 54 | 22C31625242899AA0063F6A6 /* TOViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TOViewController.m; sourceTree = ""; }; 55 | 22C3162D242899AB0063F6A6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 56 | 22C3162F242899AB0063F6A6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 57 | 22C31630242899AB0063F6A6 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 58 | 22C31636242899AB0063F6A6 /* TOPagingViewTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TOPagingViewTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 59 | 22C3163A242899AB0063F6A6 /* TODynamicPageViewTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TODynamicPageViewTests.m; sourceTree = ""; }; 60 | 22C3163C242899AB0063F6A6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 61 | 22C3167D242A40F80063F6A6 /* TOPagingView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TOPagingView.h; sourceTree = ""; }; 62 | 22C3167E242A40F80063F6A6 /* TOPagingView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TOPagingView.m; sourceTree = ""; }; 63 | /* End PBXFileReference section */ 64 | 65 | /* Begin PBXFrameworksBuildPhase section */ 66 | 22C31618242899AA0063F6A6 /* Frameworks */ = { 67 | isa = PBXFrameworksBuildPhase; 68 | buildActionMask = 2147483647; 69 | files = ( 70 | ); 71 | runOnlyForDeploymentPostprocessing = 0; 72 | }; 73 | 22C31633242899AB0063F6A6 /* Frameworks */ = { 74 | isa = PBXFrameworksBuildPhase; 75 | buildActionMask = 2147483647; 76 | files = ( 77 | ); 78 | runOnlyForDeploymentPostprocessing = 0; 79 | }; 80 | /* End PBXFrameworksBuildPhase section */ 81 | 82 | /* Begin PBXGroup section */ 83 | 22C31612242899AA0063F6A6 = { 84 | isa = PBXGroup; 85 | children = ( 86 | 22C3167C242A3C460063F6A6 /* TOPagingView */, 87 | 22C3161D242899AA0063F6A6 /* TOPagingViewExample */, 88 | 22C31639242899AB0063F6A6 /* TOPagingViewTests */, 89 | 227BEDFC27AC1879009D6B6D /* TOPagingView.podspec */, 90 | 227BEDFB27AC186F009D6B6D /* README.md */, 91 | 227BEDF927AC1867009D6B6D /* CHANGELOG.md */, 92 | 22C3161C242899AA0063F6A6 /* Products */, 93 | ); 94 | sourceTree = ""; 95 | }; 96 | 22C3161C242899AA0063F6A6 /* Products */ = { 97 | isa = PBXGroup; 98 | children = ( 99 | 22C3161B242899AA0063F6A6 /* TOPagingViewExample.app */, 100 | 22C31636242899AB0063F6A6 /* TOPagingViewTests.xctest */, 101 | ); 102 | name = Products; 103 | sourceTree = ""; 104 | }; 105 | 22C3161D242899AA0063F6A6 /* TOPagingViewExample */ = { 106 | isa = PBXGroup; 107 | children = ( 108 | 22C3161E242899AA0063F6A6 /* TOAppDelegate.h */, 109 | 22C3161F242899AA0063F6A6 /* TOAppDelegate.m */, 110 | 22C31624242899AA0063F6A6 /* TOViewController.h */, 111 | 22C31625242899AA0063F6A6 /* TOViewController.m */, 112 | 22ADED22242B2ADD004A7854 /* TOTestPageView.h */, 113 | 22ADED23242B2ADD004A7854 /* TOTestPageView.m */, 114 | 22C3162C242899AB0063F6A6 /* LaunchScreen.storyboard */, 115 | 22C3162F242899AB0063F6A6 /* Info.plist */, 116 | 22C31630242899AB0063F6A6 /* main.m */, 117 | ); 118 | path = TOPagingViewExample; 119 | sourceTree = ""; 120 | }; 121 | 22C31639242899AB0063F6A6 /* TOPagingViewTests */ = { 122 | isa = PBXGroup; 123 | children = ( 124 | 22C3163A242899AB0063F6A6 /* TODynamicPageViewTests.m */, 125 | 22C3163C242899AB0063F6A6 /* Info.plist */, 126 | ); 127 | path = TOPagingViewTests; 128 | sourceTree = ""; 129 | }; 130 | 22C3167C242A3C460063F6A6 /* TOPagingView */ = { 131 | isa = PBXGroup; 132 | children = ( 133 | 22C3167D242A40F80063F6A6 /* TOPagingView.h */, 134 | 22C3167E242A40F80063F6A6 /* TOPagingView.m */, 135 | ); 136 | path = TOPagingView; 137 | sourceTree = ""; 138 | }; 139 | /* End PBXGroup section */ 140 | 141 | /* Begin PBXNativeTarget section */ 142 | 22C3161A242899AA0063F6A6 /* TOPagingViewExample */ = { 143 | isa = PBXNativeTarget; 144 | buildConfigurationList = 22C3163F242899AB0063F6A6 /* Build configuration list for PBXNativeTarget "TOPagingViewExample" */; 145 | buildPhases = ( 146 | 22C31617242899AA0063F6A6 /* Sources */, 147 | 22C31618242899AA0063F6A6 /* Frameworks */, 148 | 22C31619242899AA0063F6A6 /* Resources */, 149 | 22ADED27242C587C004A7854 /* ShellScript */, 150 | ); 151 | buildRules = ( 152 | ); 153 | dependencies = ( 154 | ); 155 | name = TOPagingViewExample; 156 | productName = TODynamicPageView; 157 | productReference = 22C3161B242899AA0063F6A6 /* TOPagingViewExample.app */; 158 | productType = "com.apple.product-type.application"; 159 | }; 160 | 22C31635242899AB0063F6A6 /* TOPagingViewTests */ = { 161 | isa = PBXNativeTarget; 162 | buildConfigurationList = 22C31642242899AB0063F6A6 /* Build configuration list for PBXNativeTarget "TOPagingViewTests" */; 163 | buildPhases = ( 164 | 22C31632242899AB0063F6A6 /* Sources */, 165 | 22C31633242899AB0063F6A6 /* Frameworks */, 166 | 22C31634242899AB0063F6A6 /* Resources */, 167 | ); 168 | buildRules = ( 169 | ); 170 | dependencies = ( 171 | 22C31638242899AB0063F6A6 /* PBXTargetDependency */, 172 | ); 173 | name = TOPagingViewTests; 174 | productName = TODynamicPageViewTests; 175 | productReference = 22C31636242899AB0063F6A6 /* TOPagingViewTests.xctest */; 176 | productType = "com.apple.product-type.bundle.unit-test"; 177 | }; 178 | /* End PBXNativeTarget section */ 179 | 180 | /* Begin PBXProject section */ 181 | 22C31613242899AA0063F6A6 /* Project object */ = { 182 | isa = PBXProject; 183 | attributes = { 184 | LastUpgradeCheck = 1330; 185 | ORGANIZATIONNAME = "Tim Oliver"; 186 | TargetAttributes = { 187 | 22AD8C13284B25C6009F6265 = { 188 | CreatedOnToolsVersion = 13.3; 189 | }; 190 | 22C3161A242899AA0063F6A6 = { 191 | CreatedOnToolsVersion = 11.3.1; 192 | }; 193 | 22C31635242899AB0063F6A6 = { 194 | CreatedOnToolsVersion = 11.3.1; 195 | TestTargetID = 22C3161A242899AA0063F6A6; 196 | }; 197 | }; 198 | }; 199 | buildConfigurationList = 22C31616242899AA0063F6A6 /* Build configuration list for PBXProject "TOPagingView" */; 200 | compatibilityVersion = "Xcode 9.3"; 201 | developmentRegion = en; 202 | hasScannedForEncodings = 0; 203 | knownRegions = ( 204 | en, 205 | Base, 206 | ); 207 | mainGroup = 22C31612242899AA0063F6A6; 208 | productRefGroup = 22C3161C242899AA0063F6A6 /* Products */; 209 | projectDirPath = ""; 210 | projectRoot = ""; 211 | targets = ( 212 | 22C3161A242899AA0063F6A6 /* TOPagingViewExample */, 213 | 22C31635242899AB0063F6A6 /* TOPagingViewTests */, 214 | 22AD8C13284B25C6009F6265 /* OCLint */, 215 | ); 216 | }; 217 | /* End PBXProject section */ 218 | 219 | /* Begin PBXResourcesBuildPhase section */ 220 | 22C31619242899AA0063F6A6 /* Resources */ = { 221 | isa = PBXResourcesBuildPhase; 222 | buildActionMask = 2147483647; 223 | files = ( 224 | 22C3162E242899AB0063F6A6 /* LaunchScreen.storyboard in Resources */, 225 | 227BEDFA27AC1867009D6B6D /* CHANGELOG.md in Resources */, 226 | ); 227 | runOnlyForDeploymentPostprocessing = 0; 228 | }; 229 | 22C31634242899AB0063F6A6 /* Resources */ = { 230 | isa = PBXResourcesBuildPhase; 231 | buildActionMask = 2147483647; 232 | files = ( 233 | ); 234 | runOnlyForDeploymentPostprocessing = 0; 235 | }; 236 | /* End PBXResourcesBuildPhase section */ 237 | 238 | /* Begin PBXShellScriptBuildPhase section */ 239 | 22AD8C17284B25DA009F6265 /* ShellScript */ = { 240 | isa = PBXShellScriptBuildPhase; 241 | buildActionMask = 2147483647; 242 | files = ( 243 | ); 244 | inputFileListPaths = ( 245 | ); 246 | inputPaths = ( 247 | ); 248 | outputFileListPaths = ( 249 | ); 250 | outputPaths = ( 251 | ); 252 | runOnlyForDeploymentPostprocessing = 0; 253 | shellPath = /bin/sh; 254 | shellScript = "# brew install --cask oclint\n\nexport PATH=~/.rbenv/shims:$PATH\neval \"$(/opt/homebrew/bin/brew shellenv)\"\n\nunset LLVM_TARGET_TRIPLE_SUFFIX\n\nxcodebuild clean\nxcodebuild COMPILER_INDEX_STORE_ENABLE=NO | xcpretty -r json-compilation-database --output compile_commands.json\n\n# Rules\nLINT_LONG_LINE=300\nLINT_LONG_VARIABLE_NAME=64\nLINT_LONG_METHOD=150\n\nLINT_RULES=\"-rc LONG_LINE=${LINT_LONG_LINE} \\\n -rc LONG_VARIABLE_NAME=${LINT_LONG_VARIABLE_NAME} \\\n -rc LONG_METHOD=${LINT_LONG_METHOD}\"\n\n# Threshold.\nLINT_PRIORITY_1_THRESHOLD=0\nLINT_PRIORITY_2_THRESHOLD=20\nLINT_PRIORITY_3_THRESHOLD=30\nLINT_THRESHOLD = \"-max-priority-1=${LINT_PRIORITY_1_THRESHOLD} \\\n -max-priority-2=${LINT_PRIORITY_2_THRESHOLD} \\\n -max-priority-3=${LINT_PRIORITY_3_THRESHOLD}\"\n\n# Excludes\n# you can use grep-like regular expressions syntax,\nLINT_EXCLUDES=\"Pods|lib\"\n\noclint-json-compilation-database \\\n -exclude ${LINT_EXCLUDES} \\\n -- \\\n -report-type xcode \\\n ${LINT_RULES} \\\n\n"; 255 | }; 256 | 22ADED27242C587C004A7854 /* ShellScript */ = { 257 | isa = PBXShellScriptBuildPhase; 258 | buildActionMask = 2147483647; 259 | files = ( 260 | ); 261 | inputFileListPaths = ( 262 | ); 263 | inputPaths = ( 264 | ); 265 | outputFileListPaths = ( 266 | ); 267 | outputPaths = ( 268 | ); 269 | runOnlyForDeploymentPostprocessing = 0; 270 | shellPath = /bin/sh; 271 | shellScript = "export REVEAL_SERVER_FILENAME=\"RevealServer.framework\"\n\n# Update this path to point to the location of RevealServer.framework in your project.\nexport REVEAL_SERVER_PATH=\"${SRCROOT}/${REVEAL_SERVER_FILENAME}\"\n\n# If configuration is not Debug, skip this script.\n[ \"${CONFIGURATION}\" != \"Debug\" ] && exit 0\n\n# If RevealServer.framework exists at the specified path, run code signing script.\nif [ -d \"${REVEAL_SERVER_PATH}\" ]; then\n \"${REVEAL_SERVER_PATH}/Scripts/copy_and_codesign_revealserver.sh\"\nelse\n echo \"Reveal Server not loaded: RevealServer.framework could not be found.\"\nfi\n"; 272 | }; 273 | /* End PBXShellScriptBuildPhase section */ 274 | 275 | /* Begin PBXSourcesBuildPhase section */ 276 | 22C31617242899AA0063F6A6 /* Sources */ = { 277 | isa = PBXSourcesBuildPhase; 278 | buildActionMask = 2147483647; 279 | files = ( 280 | 22C31626242899AA0063F6A6 /* TOViewController.m in Sources */, 281 | 22C31620242899AA0063F6A6 /* TOAppDelegate.m in Sources */, 282 | 22ADED24242B2ADD004A7854 /* TOTestPageView.m in Sources */, 283 | 22C3167F242A40F80063F6A6 /* TOPagingView.m in Sources */, 284 | 22C31631242899AB0063F6A6 /* main.m in Sources */, 285 | ); 286 | runOnlyForDeploymentPostprocessing = 0; 287 | }; 288 | 22C31632242899AB0063F6A6 /* Sources */ = { 289 | isa = PBXSourcesBuildPhase; 290 | buildActionMask = 2147483647; 291 | files = ( 292 | 22C3163B242899AB0063F6A6 /* TODynamicPageViewTests.m in Sources */, 293 | ); 294 | runOnlyForDeploymentPostprocessing = 0; 295 | }; 296 | /* End PBXSourcesBuildPhase section */ 297 | 298 | /* Begin PBXTargetDependency section */ 299 | 22C31638242899AB0063F6A6 /* PBXTargetDependency */ = { 300 | isa = PBXTargetDependency; 301 | target = 22C3161A242899AA0063F6A6 /* TOPagingViewExample */; 302 | targetProxy = 22C31637242899AB0063F6A6 /* PBXContainerItemProxy */; 303 | }; 304 | /* End PBXTargetDependency section */ 305 | 306 | /* Begin PBXVariantGroup section */ 307 | 22C3162C242899AB0063F6A6 /* LaunchScreen.storyboard */ = { 308 | isa = PBXVariantGroup; 309 | children = ( 310 | 22C3162D242899AB0063F6A6 /* Base */, 311 | ); 312 | name = LaunchScreen.storyboard; 313 | sourceTree = ""; 314 | }; 315 | /* End PBXVariantGroup section */ 316 | 317 | /* Begin XCBuildConfiguration section */ 318 | 22AD8C14284B25C6009F6265 /* Debug */ = { 319 | isa = XCBuildConfiguration; 320 | buildSettings = { 321 | CODE_SIGN_STYLE = Automatic; 322 | DEVELOPMENT_TEAM = 6LF3GMKZAB; 323 | PRODUCT_NAME = "$(TARGET_NAME)"; 324 | }; 325 | name = Debug; 326 | }; 327 | 22AD8C15284B25C6009F6265 /* Release */ = { 328 | isa = XCBuildConfiguration; 329 | buildSettings = { 330 | CODE_SIGN_STYLE = Automatic; 331 | DEVELOPMENT_TEAM = 6LF3GMKZAB; 332 | PRODUCT_NAME = "$(TARGET_NAME)"; 333 | }; 334 | name = Release; 335 | }; 336 | 22C3163D242899AB0063F6A6 /* Debug */ = { 337 | isa = XCBuildConfiguration; 338 | buildSettings = { 339 | ALWAYS_SEARCH_USER_PATHS = NO; 340 | CLANG_ANALYZER_NONNULL = YES; 341 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 342 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 343 | CLANG_CXX_LIBRARY = "libc++"; 344 | CLANG_ENABLE_MODULES = YES; 345 | CLANG_ENABLE_OBJC_ARC = YES; 346 | CLANG_ENABLE_OBJC_WEAK = YES; 347 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 348 | CLANG_WARN_BOOL_CONVERSION = YES; 349 | CLANG_WARN_COMMA = YES; 350 | CLANG_WARN_CONSTANT_CONVERSION = YES; 351 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 352 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 353 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 354 | CLANG_WARN_EMPTY_BODY = YES; 355 | CLANG_WARN_ENUM_CONVERSION = YES; 356 | CLANG_WARN_INFINITE_RECURSION = YES; 357 | CLANG_WARN_INT_CONVERSION = YES; 358 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 359 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 360 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 361 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 362 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 363 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 364 | CLANG_WARN_STRICT_PROTOTYPES = YES; 365 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 366 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 367 | CLANG_WARN_UNREACHABLE_CODE = YES; 368 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 369 | COPY_PHASE_STRIP = NO; 370 | DEBUG_INFORMATION_FORMAT = dwarf; 371 | ENABLE_STRICT_OBJC_MSGSEND = YES; 372 | ENABLE_TESTABILITY = YES; 373 | GCC_C_LANGUAGE_STANDARD = gnu11; 374 | GCC_DYNAMIC_NO_PIC = NO; 375 | GCC_NO_COMMON_BLOCKS = YES; 376 | GCC_OPTIMIZATION_LEVEL = 0; 377 | GCC_PREPROCESSOR_DEFINITIONS = ( 378 | "DEBUG=1", 379 | "$(inherited)", 380 | ); 381 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 382 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 383 | GCC_WARN_UNDECLARED_SELECTOR = YES; 384 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 385 | GCC_WARN_UNUSED_FUNCTION = YES; 386 | GCC_WARN_UNUSED_VARIABLE = YES; 387 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 388 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 389 | MTL_FAST_MATH = YES; 390 | ONLY_ACTIVE_ARCH = YES; 391 | SDKROOT = iphoneos; 392 | }; 393 | name = Debug; 394 | }; 395 | 22C3163E242899AB0063F6A6 /* 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++14"; 402 | CLANG_CXX_LIBRARY = "libc++"; 403 | CLANG_ENABLE_MODULES = YES; 404 | CLANG_ENABLE_OBJC_ARC = YES; 405 | CLANG_ENABLE_OBJC_WEAK = YES; 406 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 407 | CLANG_WARN_BOOL_CONVERSION = YES; 408 | CLANG_WARN_COMMA = YES; 409 | CLANG_WARN_CONSTANT_CONVERSION = YES; 410 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 411 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 412 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 413 | CLANG_WARN_EMPTY_BODY = YES; 414 | CLANG_WARN_ENUM_CONVERSION = YES; 415 | CLANG_WARN_INFINITE_RECURSION = YES; 416 | CLANG_WARN_INT_CONVERSION = YES; 417 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 418 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 419 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 420 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 421 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 422 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 423 | CLANG_WARN_STRICT_PROTOTYPES = YES; 424 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 425 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 426 | CLANG_WARN_UNREACHABLE_CODE = YES; 427 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 428 | COPY_PHASE_STRIP = NO; 429 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 430 | ENABLE_NS_ASSERTIONS = NO; 431 | ENABLE_STRICT_OBJC_MSGSEND = YES; 432 | GCC_C_LANGUAGE_STANDARD = gnu11; 433 | GCC_NO_COMMON_BLOCKS = YES; 434 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 435 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 436 | GCC_WARN_UNDECLARED_SELECTOR = YES; 437 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 438 | GCC_WARN_UNUSED_FUNCTION = YES; 439 | GCC_WARN_UNUSED_VARIABLE = YES; 440 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 441 | MTL_ENABLE_DEBUG_INFO = NO; 442 | MTL_FAST_MATH = YES; 443 | SDKROOT = iphoneos; 444 | VALIDATE_PRODUCT = YES; 445 | }; 446 | name = Release; 447 | }; 448 | 22C31640242899AB0063F6A6 /* Debug */ = { 449 | isa = XCBuildConfiguration; 450 | buildSettings = { 451 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 452 | CODE_SIGN_STYLE = Automatic; 453 | DEVELOPMENT_TEAM = 6LF3GMKZAB; 454 | FRAMEWORK_SEARCH_PATHS = ( 455 | "$(inherited)", 456 | "$(SRCROOT)", 457 | ); 458 | INFOPLIST_FILE = TOPagingViewExample/Info.plist; 459 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 460 | LD_RUNPATH_SEARCH_PATHS = ( 461 | "$(inherited)", 462 | "@executable_path/Frameworks", 463 | ); 464 | PRODUCT_BUNDLE_IDENTIFIER = dev.tim.TOPagingViewExample; 465 | PRODUCT_NAME = "$(TARGET_NAME)"; 466 | SUPPORTS_MACCATALYST = YES; 467 | TARGETED_DEVICE_FAMILY = "1,2"; 468 | }; 469 | name = Debug; 470 | }; 471 | 22C31641242899AB0063F6A6 /* Release */ = { 472 | isa = XCBuildConfiguration; 473 | buildSettings = { 474 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 475 | CODE_SIGN_STYLE = Automatic; 476 | DEVELOPMENT_TEAM = 6LF3GMKZAB; 477 | FRAMEWORK_SEARCH_PATHS = ( 478 | "$(inherited)", 479 | "$(SRCROOT)", 480 | ); 481 | INFOPLIST_FILE = TOPagingViewExample/Info.plist; 482 | IPHONEOS_DEPLOYMENT_TARGET = 12.0; 483 | LD_RUNPATH_SEARCH_PATHS = ( 484 | "$(inherited)", 485 | "@executable_path/Frameworks", 486 | ); 487 | PRODUCT_BUNDLE_IDENTIFIER = dev.tim.TOPagingViewExample; 488 | PRODUCT_NAME = "$(TARGET_NAME)"; 489 | SUPPORTS_MACCATALYST = YES; 490 | TARGETED_DEVICE_FAMILY = "1,2"; 491 | }; 492 | name = Release; 493 | }; 494 | 22C31643242899AB0063F6A6 /* Debug */ = { 495 | isa = XCBuildConfiguration; 496 | buildSettings = { 497 | BUNDLE_LOADER = "$(TEST_HOST)"; 498 | CODE_SIGN_STYLE = Automatic; 499 | DEVELOPMENT_TEAM = 6LF3GMKZAB; 500 | INFOPLIST_FILE = TOPagingViewTests/Info.plist; 501 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 502 | LD_RUNPATH_SEARCH_PATHS = ( 503 | "$(inherited)", 504 | "@executable_path/Frameworks", 505 | "@loader_path/Frameworks", 506 | ); 507 | PRODUCT_BUNDLE_IDENTIFIER = dev.tim.TOPagingViewTests; 508 | PRODUCT_NAME = "$(TARGET_NAME)"; 509 | TARGETED_DEVICE_FAMILY = "1,2"; 510 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TOPagingView.app/TOPagingView"; 511 | }; 512 | name = Debug; 513 | }; 514 | 22C31644242899AB0063F6A6 /* Release */ = { 515 | isa = XCBuildConfiguration; 516 | buildSettings = { 517 | BUNDLE_LOADER = "$(TEST_HOST)"; 518 | CODE_SIGN_STYLE = Automatic; 519 | DEVELOPMENT_TEAM = 6LF3GMKZAB; 520 | INFOPLIST_FILE = TOPagingViewTests/Info.plist; 521 | IPHONEOS_DEPLOYMENT_TARGET = 13.2; 522 | LD_RUNPATH_SEARCH_PATHS = ( 523 | "$(inherited)", 524 | "@executable_path/Frameworks", 525 | "@loader_path/Frameworks", 526 | ); 527 | PRODUCT_BUNDLE_IDENTIFIER = dev.tim.TOPagingViewTests; 528 | PRODUCT_NAME = "$(TARGET_NAME)"; 529 | TARGETED_DEVICE_FAMILY = "1,2"; 530 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TOPagingView.app/TOPagingView"; 531 | }; 532 | name = Release; 533 | }; 534 | /* End XCBuildConfiguration section */ 535 | 536 | /* Begin XCConfigurationList section */ 537 | 22AD8C16284B25C6009F6265 /* Build configuration list for PBXAggregateTarget "OCLint" */ = { 538 | isa = XCConfigurationList; 539 | buildConfigurations = ( 540 | 22AD8C14284B25C6009F6265 /* Debug */, 541 | 22AD8C15284B25C6009F6265 /* Release */, 542 | ); 543 | defaultConfigurationIsVisible = 0; 544 | defaultConfigurationName = Release; 545 | }; 546 | 22C31616242899AA0063F6A6 /* Build configuration list for PBXProject "TOPagingView" */ = { 547 | isa = XCConfigurationList; 548 | buildConfigurations = ( 549 | 22C3163D242899AB0063F6A6 /* Debug */, 550 | 22C3163E242899AB0063F6A6 /* Release */, 551 | ); 552 | defaultConfigurationIsVisible = 0; 553 | defaultConfigurationName = Release; 554 | }; 555 | 22C3163F242899AB0063F6A6 /* Build configuration list for PBXNativeTarget "TOPagingViewExample" */ = { 556 | isa = XCConfigurationList; 557 | buildConfigurations = ( 558 | 22C31640242899AB0063F6A6 /* Debug */, 559 | 22C31641242899AB0063F6A6 /* Release */, 560 | ); 561 | defaultConfigurationIsVisible = 0; 562 | defaultConfigurationName = Release; 563 | }; 564 | 22C31642242899AB0063F6A6 /* Build configuration list for PBXNativeTarget "TOPagingViewTests" */ = { 565 | isa = XCConfigurationList; 566 | buildConfigurations = ( 567 | 22C31643242899AB0063F6A6 /* Debug */, 568 | 22C31644242899AB0063F6A6 /* Release */, 569 | ); 570 | defaultConfigurationIsVisible = 0; 571 | defaultConfigurationName = Release; 572 | }; 573 | /* End XCConfigurationList section */ 574 | }; 575 | rootObject = 22C31613242899AA0063F6A6 /* Project object */; 576 | } 577 | -------------------------------------------------------------------------------- /TOPagingView.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /TOPagingView.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /TOPagingView.xcodeproj/xcshareddata/xcschemes/TOPagingViewExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /TOPagingView/TOPagingView.h: -------------------------------------------------------------------------------- 1 | // 2 | // TOPagingView.h 3 | // 4 | // Copyright 2018-2023 Timothy Oliver. All rights reserved. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to 8 | // deal in the Software without restriction, including without limitation the 9 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 10 | // sell copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in 14 | // all copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 21 | // IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | #import 24 | 25 | NS_ASSUME_NONNULL_BEGIN 26 | 27 | @class TOPagingView; 28 | 29 | //------------------------------------------------------------------- 30 | 31 | /// An enumeration of directions in which the scroll view may display pages. 32 | typedef NS_ENUM(NSInteger, TOPagingViewDirection) { 33 | /// Pages ascend from the left, to the right. 34 | TOPagingViewDirectionLeftToRight = 0, 35 | 36 | /// Pages ascend from the right, to the left. 37 | TOPagingViewDirectionRightToLeft = 1 38 | } NS_SWIFT_NAME(PagingViewDirection); 39 | 40 | /// An enumeration describing the kind of page being requested by the data source. 41 | typedef NS_ENUM(NSInteger, TOPagingViewPageType) { 42 | /// The current page that will be visible on screen initially. 43 | TOPagingViewPageTypeCurrent, 44 | 45 | ///The next page sequentially after the current page. 46 | TOPagingViewPageTypeNext, 47 | 48 | /// The previous page sequentially before the current page. 49 | TOPagingViewPageTypePrevious 50 | } NS_SWIFT_NAME(PagingViewPageType); 51 | 52 | //------------------------------------------------------------------- 53 | 54 | /// Optional protocol that page views may implement. 55 | NS_SWIFT_NAME(PagingViewPage) 56 | @protocol TOPagingViewPage 57 | 58 | @optional 59 | 60 | /// A unique string value that can be used to let the pager view 61 | /// dequeue pre-made objects with the same identifier, or if pre-registered, 62 | /// create new instances automatically on request. 63 | /// 64 | /// If this property is not overridden, the page will be treated as the default 65 | /// type that will be returned whenever the identifier is nil. 66 | + (NSString *)pageIdentifier; 67 | 68 | /// A globally unique identifier that can be used to uniquely tag this specific 69 | /// page object. This can be used to retrieve the page from the pager view at a later 70 | /// time. 71 | - (NSString *)uniqueIdentifier; 72 | 73 | /// Called just before the page object is removed from the visible page set, 74 | /// and re-enqueud by the data source. 75 | /// 76 | /// Use this method to return the page to a default state, and to clear out any 77 | /// references to memory-heavy objects like images. 78 | - (void)prepareForReuse; 79 | 80 | /// The current page on screen is the first page in the current sequence. 81 | /// When dynamic page direction is enabled, scrolling past the initial page in either 82 | /// direction will start incrementing pages in that direction. 83 | - (BOOL)isInitialPage; 84 | 85 | /// Passes the current reading direction from the hosting paging view to this page. 86 | /// Use this to re-arrange any sets of subviews that depend on the direction that the pages flow in. 87 | /// - Parameter direction: The ascending direction that the pages will flow in. 88 | - (void)setPageDirection:(TOPagingViewDirection)direction; 89 | 90 | @end 91 | 92 | // ------------------------------------------------------------------- 93 | 94 | NS_SWIFT_NAME(PagingViewDataSource) 95 | @protocol TOPagingViewDataSource 96 | 97 | @required 98 | 99 | /// Called when the paging view is requesting a new page view in the current sequence in either direction. 100 | /// Use this method to dequeue, or create a new page view that will be displayed in the paging view. 101 | /// @param pagingView The paging view requesting the new page view. 102 | /// @param type The type of page to be displayed in its relation to the visible page on screen. 103 | /// @param currentPageView The current page view on screen. This can be nil if no pages have been displayed yet. 104 | - (nullable __kindof UIView *)pagingView:(TOPagingView *)pagingView 105 | pageViewForType:(TOPagingViewPageType)type 106 | currentPageView:(UIView * _Nullable)currentPageView; 107 | 108 | @end 109 | 110 | // ------------------------------------------------------------------- 111 | 112 | NS_SWIFT_NAME(PagingViewDataDelegate) 113 | @protocol TOPagingViewDelegate 114 | 115 | @optional 116 | 117 | /// Called when a transaction has started moving in a direction (eg, the user has 118 | /// started swiping in a direction, or an animation is about to start) that can potentially 119 | /// end in a page transition. Use this to start preloading content in that direction. 120 | /// @param pagingView The calling paging view instance. 121 | /// @param type The type of page that was turned to, whether the next or previous one. 122 | - (void)pagingView:(TOPagingView *)pagingView willTurnToPageOfType:(TOPagingViewPageType)type; 123 | 124 | /// Called when a page turn has crossed the turning threshold and a new page has become the current one. 125 | /// Use this to update any state around the paging view used to control the current page. 126 | /// @param pagingView The calling paging view instance. 127 | /// @param type The type of page that was turned to (This can include initial after a reload). 128 | - (void)pagingView:(TOPagingView *)pagingView didTurnToPageOfType:(TOPagingViewPageType)type; 129 | 130 | /// Called when dynamic page direction is enabled, and the user just swiped off the initial page in either 131 | /// direction, effectively committing to a new page direction. Use this to update any UI or persist the new direction 132 | /// @param pagingView The calling paging view instance. 133 | /// @param direction The new direction in which the pages are flowing. 134 | - (void)pagingView:(TOPagingView *)pagingView didChangeToPageDirection:(TOPagingViewDirection)direction; 135 | 136 | @end 137 | 138 | //------------------------------------------------------------------- 139 | 140 | /// A view that presents content as discrete horizontal scrolling pages. 141 | /// The interface has been designed so any arbitrary number of pages may be 142 | /// displayed without knowing the final number up front. 143 | NS_SWIFT_NAME(PagingView) 144 | @interface TOPagingView : UIView 145 | 146 | /// The internal scroll view wrapped by this view that controls the scrolling content. 147 | /// The delegate is available for external objects to use. 148 | @property (nonatomic, strong, readonly) UIScrollView *scrollView; 149 | 150 | /// The data source object that is in charge with configuring and providing views to this view. 151 | @property (nonatomic, weak, nullable) id dataSource; 152 | 153 | /// The delegate broadcasts page turning events, so that the data source can update its state to match. 154 | @property (nonatomic, weak, nullable) id delegate; 155 | 156 | /// Width of the spacing between pages in points (default value of 40). 157 | @property (nonatomic, assign) CGFloat pageSpacing; 158 | 159 | /// The ascending layout direction of the page views in the scroll view. 160 | @property (nonatomic, assign) TOPagingViewDirection pageScrollDirection; 161 | 162 | /// Allows users to intuitively start scrolling in either direction, 163 | /// with `pageScrollDirection` automatically updating to match. 164 | @property (nonatomic, assign) BOOL isDynamicPageDirectionEnabled; 165 | 166 | /// Registers a page view class that can be automatically instantiated as needed. 167 | /// If the class overrides `pageIdentifier`, new instances may automatically be created 168 | /// when needed. Any classes that do not override that property will become the default 169 | /// page class. 170 | - (void)registerPageViewClass:(Class)pageViewClass; 171 | 172 | /// Reload the view from scratch, including tearing down and recreating all page views 173 | - (void)reload; 174 | 175 | /// Tears down and recreates the previous and next page views from scratch, but leaves the current one alone. 176 | - (void)reloadAdjacentPages; 177 | 178 | /// Loads the previous and/or next page views only if they're not already loaded. Useful for when the data source has updated with new page data. 179 | - (void)fetchAdjacentPagesIfAvailable; 180 | 181 | /// Returns a page view from the default queue of pages, ready for re-use. 182 | - (nullable __kindof UIView *)dequeueReusablePageView; 183 | 184 | /// Returns a page view from the specific queue matching the provided identifier string. 185 | /// - Parameter identifier: The identifier of the specific page type to be returned. Generates a new instance if no more spares in the queue exist 186 | - (nullable __kindof UIView *)dequeueReusablePageViewForIdentifier:(nullable NSString *)identifier 187 | NS_SWIFT_NAME(dequeueReusablePageView(for:)); 188 | 189 | /// The currently visible primary page view on screen. 190 | - (nullable __kindof UIView *)currentPageView; 191 | 192 | /// The next page after the currently visible page on the screen. 193 | - (nullable __kindof UIView *)nextPageView; 194 | 195 | /// The previous page before the currently visible page on the screen. 196 | - (nullable __kindof UIView *)previousPageView; 197 | 198 | /// Returns all of the currently visible pages as an un-ordered set 199 | - (nullable NSSet<__kindof UIView *> *)visiblePageViews; 200 | 201 | /// Returns the visible page view for the supplied unique identifier, or nil otherwise. 202 | /// - Parameter identifier: The identifier of the specific page view to retrieve. 203 | - (nullable __kindof UIView *)pageViewForUniqueIdentifier:(NSString *)identifier 204 | NS_SWIFT_NAME(uniquePageView(for:)); 205 | 206 | /// Advance one page forward in ascending order (which will be left or right depending on direction) 207 | /// - Parameter animated: Whether the transition is animated, or updates instantly 208 | - (void)turnToNextPageAnimated:(BOOL)animated; 209 | 210 | /// Advance one page backward in descending order (which will be left or right depending on direction) 211 | /// - Parameter animated: Whether the transition is animated, or updates instantly 212 | - (void)turnToPreviousPageAnimated:(BOOL)animated; 213 | 214 | /// Advance one page to the left (Regardless of current scroll direction) 215 | /// - Parameter animated: Whether the transition is animated, or updates instantly 216 | - (void)turnToLeftPageAnimated:(BOOL)animated; 217 | 218 | /// Advance one page to the right (Regardless of current scroll direction) 219 | /// - Parameter animated: Whether the transition is animated, or updates instantly 220 | - (void)turnToRightPageAnimated:(BOOL)animated; 221 | 222 | /// Skips ahead to an arbitrary new page view. 223 | /// The data source must be updated to the new state before calling this. 224 | /// - Parameter animated: Whether the transition is animated, or updates instantly 225 | - (void)skipForwardToNewPageAnimated:(BOOL)animated; 226 | 227 | /// Skips backwards to an arbitrary new page view. 228 | /// The data source must be updated to the new state before calling this. 229 | /// - Parameter animated: Whether the transition is animated, or updates instantly 230 | - (void)skipBackwardToNewPageAnimated:(BOOL)animated; 231 | 232 | @end 233 | 234 | NS_ASSUME_NONNULL_END 235 | -------------------------------------------------------------------------------- /TOPagingView/TOPagingView.m: -------------------------------------------------------------------------------- 1 | // 2 | // TOPagingView.m 3 | // 4 | // Copyright 2018-2023 Timothy Oliver. All rights reserved. 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to 8 | // deal in the Software without restriction, including without limitation the 9 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 10 | // sell copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in 14 | // all copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 21 | // IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | #import "TOPagingView.h" 24 | 25 | /// Mark methods as being statically called to increase performance 26 | #define TOPAGINGVIEW_OBJC_DIRECT __attribute__((objc_direct)) 27 | 28 | // ----------------------------------------------------------------- 29 | 30 | /// For pages that don't specify an identifier, this string will be used. 31 | static NSString *const kTOPagingViewDefaultIdentifier = @"TOPagingView.DefaultPageIdentifier"; 32 | 33 | /// There are always 3 slots, with content insetting used to block pages on either side. 34 | static const CGFloat kTOPagingViewPageSlotCount = 3.0f; 35 | 36 | /// The amount of padding along the edge of the screen shown when the "no incoming page" animation plays 37 | static const CGFloat kTOPagingViewBumperWidthCompact = 48.0f; 38 | static const CGFloat kTOPagingViewBumperWidthRegular = 96.0f; 39 | 40 | /// The timing parameters when playing a precanned transition animation. 41 | static const CFTimeInterval kTOPagingViewAnimationDuration = 0.4f; 42 | static const CGPoint kTOPagingViewAnimationControlPoint1 = (CGPoint){0.3f, 0.9f}; 43 | static const CGPoint kTOPagingViewAnimationControlPoint2 = (CGPoint){0.45f, 1.0f}; 44 | static const NSInteger kTOPagingViewAnimationOptions = (UIViewAnimationOptionAllowUserInteraction); 45 | 46 | // ----------------------------------------------------------------- 47 | 48 | /// A struct to cache which methods the current delegate implements. */ 49 | typedef struct { 50 | unsigned int delegateWillTurnToPage:1; 51 | unsigned int delegateDidTurnToPage:1; 52 | unsigned int delegateDidChangeToPageDirection:1; 53 | } TOPagingViewDelegateFlags; 54 | 55 | // ----------------------------------------------------------------- 56 | 57 | /// A struct to cache which methods each page view class implements. 58 | typedef struct { 59 | unsigned int protocolPageIdentifier:1; 60 | unsigned int protocolUniqueIdentifier:1; 61 | unsigned int protocolPrepareForReuse:1; 62 | unsigned int protocolIsInitialPage:1; 63 | unsigned int protocolSetPageDirection:1; 64 | } TOPageViewProtocolFlags; 65 | 66 | @interface TOPageViewProtocolCache : NSObject 67 | @property (nonatomic, assign) TOPageViewProtocolFlags flags; 68 | @end 69 | 70 | @implementation TOPageViewProtocolCache 71 | @end 72 | 73 | // ----------------------------------------------------------------- 74 | // Convenience functions for easier mapping Objective-C and C constructs 75 | 76 | /// Convert an Objective-C class pointer into an NSValue that can be stored in a dictionary 77 | static inline NSValue *TOPagingViewValueForClass(Class *class) { 78 | return [NSValue valueWithBytes:class objCType:@encode(Class)]; 79 | } 80 | 81 | /// Convert an Objective-C class that was encoded to NSValue back out again 82 | static inline Class TOPagingViewClassForValue(NSValue *value) { 83 | Class class; [value getValue:&class]; return class; 84 | } 85 | 86 | // ----------------------------------------------------------------- 87 | 88 | @interface TOPagingView () 89 | 90 | /// The scroll view managed by this container. 91 | @property (nonatomic, strong, readwrite) UIScrollView *scrollView; 92 | 93 | /// A collection of all of the page view objects that were once used, and are pending re-use. 94 | @property (nonatomic, strong) NSMutableDictionary *queuedPages; 95 | 96 | /// A collection of all of the registered page classes, saved against their identifiers. 97 | @property (nonatomic, strong) NSMutableDictionary *registeredPageViewClasses; 98 | 99 | /// The views that are all currently in the scroll view, in specific order. 100 | @property (nonatomic, weak, readwrite) UIView *currentPageView; 101 | @property (nonatomic, weak, readwrite) UIView *nextPageView; 102 | @property (nonatomic, weak, readwrite) UIView *previousPageView; 103 | 104 | /// Flags to ensure the data source isn't thrashed if it doesn't return a page the first time. 105 | @property (nonatomic, assign) BOOL hasNextPage; 106 | @property (nonatomic, assign) BOOL hasPreviousPage; 107 | 108 | /// Struct to cache the state of the delegate for performance. 109 | @property (nonatomic, assign) TOPagingViewDelegateFlags delegateFlags; 110 | 111 | /// Struct to cache the protocol state of each type of page view class used in this session. 112 | @property (nonatomic, strong) NSMutableDictionary *pageViewProtocolFlags; 113 | 114 | /// Disable automatic layout when manually laying out content. 115 | @property (nonatomic, assign) BOOL disableLayout; 116 | 117 | /// A dictionary that holds references to any pages with unique identifiers. 118 | @property (nonatomic, strong) NSMutableDictionary *uniqueIdentifierPages; 119 | 120 | /// State tracking for when a user is dragging their finger on screen. 121 | @property (nonatomic, assign) CGFloat draggingOrigin; 122 | @property (nonatomic, assign) TOPagingViewPageType draggingDirectionType; 123 | 124 | /// State tracking for handling offloading view configuration to another run-loop tick 125 | @property (nonatomic, assign) BOOL needsNextPage; 126 | @property (nonatomic, assign) BOOL needsPreviousPage; 127 | 128 | /// The animator used to play smooth transitions when turning pages 129 | @property (nonatomic, strong) UIViewPropertyAnimator *pageViewAnimator; 130 | 131 | @end 132 | 133 | // ----------------------------------------------------------------- 134 | 135 | @implementation TOPagingView 136 | 137 | #pragma mark - Object Creation - 138 | 139 | - (instancetype)init 140 | { 141 | self = [super init]; 142 | if (self) { [self _setUp]; } 143 | return self; 144 | } 145 | 146 | - (instancetype)initWithFrame:(CGRect)frame 147 | { 148 | self = [super initWithFrame:frame]; 149 | if (self) { [self _setUp]; } 150 | return self; 151 | } 152 | 153 | - (instancetype)initWithCoder:(NSCoder *)coder 154 | { 155 | self = [super initWithCoder:coder]; 156 | if (self) { [self _setUp]; } 157 | return self; 158 | } 159 | 160 | - (void)_setUp TOPAGINGVIEW_OBJC_DIRECT 161 | { 162 | // Set default values 163 | _pageSpacing = 40.0f; 164 | _queuedPages = [NSMutableDictionary dictionary]; 165 | _pageViewProtocolFlags = [NSMutableDictionary dictionary]; 166 | memset(&_delegateFlags, 0, sizeof(TOPagingViewDelegateFlags)); 167 | 168 | // Configure the main properties of this view 169 | self.clipsToBounds = YES; // The scroll view intentionally overlaps, so this view MUST clip. 170 | self.backgroundColor = [UIColor clearColor]; 171 | 172 | // Create and configure the scroll view 173 | _scrollView = [[UIScrollView alloc] initWithFrame:CGRectZero]; 174 | [self _configureScrollView]; 175 | [self addSubview:_scrollView]; 176 | 177 | // Configure the page view animator 178 | UICubicTimingParameters *const cubicTiming = [[UICubicTimingParameters alloc] initWithControlPoint1:kTOPagingViewAnimationControlPoint1 179 | controlPoint2:kTOPagingViewAnimationControlPoint2]; 180 | _pageViewAnimator = [[UIViewPropertyAnimator alloc] initWithDuration:kTOPagingViewAnimationDuration 181 | timingParameters:cubicTiming]; 182 | } 183 | 184 | - (void)_configureScrollView TOPAGINGVIEW_OBJC_DIRECT 185 | { 186 | UIScrollView *const scrollView = _scrollView; 187 | 188 | // Set the frame of the scrollview now so we can start 189 | // calculating the inset 190 | scrollView.frame = TOPagingViewScrollViewFrame(self); 191 | 192 | // Set the scroll behaviour to snap between pages 193 | scrollView.pagingEnabled = YES; 194 | 195 | // Disable auto status bar insetting 196 | if (@available(iOS 11.0, *)) { 197 | scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; 198 | } 199 | 200 | // Never show the indicators 201 | scrollView.showsHorizontalScrollIndicator = NO; 202 | scrollView.showsVerticalScrollIndicator = NO; 203 | 204 | // Register an observer to capture when the scroll view scrolls. 205 | // Doing it this way lets us leave the delegate available for external objects to use. 206 | [scrollView addObserver:self forKeyPath:@"contentOffset" options:0 context:(__bridge void *)self]; 207 | 208 | // Enable scrolling by clicking and dragging with the mouse 209 | // The only way to do this is via a private API. FB10593893 was filed to request this property is made public. 210 | if (@available(iOS 14.0, *)) { 211 | NSArray *const selectorComponents = @[@"_", @"set", @"SupportsPointerDragScrolling:"]; 212 | SEL selector = NSSelectorFromString([selectorComponents componentsJoinedByString:@""]); 213 | if ([scrollView respondsToSelector:selector]) { 214 | [scrollView performSelector:selector withObject:@(YES) afterDelay:0]; 215 | } 216 | } 217 | } 218 | 219 | - (void)dealloc 220 | { 221 | // Make sure to remove the observer before we deallocate otherwise it can potentially cause a crash. 222 | [_scrollView removeObserver:self forKeyPath:@"contentOffset"]; 223 | } 224 | 225 | #pragma mark - View Lifecycle - 226 | 227 | - (void)setFrame:(CGRect)frame { 228 | const CGRect oldFrame = self.frame; 229 | [super setFrame:frame]; 230 | if (!CGRectEqualToRect(frame, oldFrame)) { 231 | [self layoutContent]; 232 | } 233 | } 234 | 235 | - (void)layoutSubviews { 236 | [super layoutSubviews]; 237 | [self layoutContent]; 238 | } 239 | 240 | - (void)layoutContent 241 | { 242 | // If need be, request new next/previous pages 243 | [self _requestPendingPages]; 244 | 245 | UIScrollView *const scrollView = _scrollView; 246 | const CGRect newScrollViewFrame = TOPagingViewScrollViewFrame(self); 247 | 248 | // We don't need to perform any new sizing calculations unless the frame changed enough to warrant 249 | // also changing the content size 250 | if (CGSizeEqualToSize(_scrollView.frame.size, newScrollViewFrame.size)) { 251 | return; 252 | } 253 | 254 | // Disable the observer while we update the scroll view 255 | _disableLayout = YES; 256 | 257 | // In case the width is changing, re-set the content size and offset to match 258 | const CGFloat oldContentWidth = scrollView.contentSize.width; 259 | const CGFloat oldOffsetMid = scrollView.contentOffset.x + (scrollView.frame.size.width * 0.5f); 260 | 261 | // Layout the scroll view. 262 | // In order to allow spaces between the pages, the scroll view needs to be 263 | // slightly wider than this container view. 264 | scrollView.frame = newScrollViewFrame; 265 | 266 | // Update the content size of the scroll view 267 | [self _updateContentSize]; 268 | 269 | // Update the content offset to match the amount that the width changed 270 | // (Only do this if there actually was an old content width, otherwise we might get a NaN error) 271 | if (oldContentWidth > FLT_EPSILON) { 272 | const CGFloat newOffsetMid = oldOffsetMid * (scrollView.contentSize.width / oldContentWidth); 273 | const CGFloat contentOffset = newOffsetMid - (scrollView.frame.size.width * 0.5f); 274 | scrollView.contentOffset = (CGPoint){contentOffset, 0.0f}; 275 | } 276 | 277 | // Re-enable the observer 278 | _disableLayout = NO; 279 | 280 | // Layout the page subviews 281 | _nextPageView.frame = TOPagingViewNextPageFrame(self); 282 | _currentPageView.frame = TOPagingViewCurrentPageFrame(self); 283 | _previousPageView.frame = TOPagingViewPreviousPageFrame(self); 284 | } 285 | 286 | - (void)didMoveToSuperview 287 | { 288 | [super didMoveToSuperview]; 289 | [self reload]; 290 | } 291 | 292 | #pragma mark - Scroll View Management - 293 | 294 | - (void)_updateContentSize TOPAGINGVIEW_OBJC_DIRECT 295 | { 296 | // With the three pages set, calculate the scrolling content size 297 | CGSize contentSize = self.bounds.size; 298 | contentSize.width = TOPagingViewScrollViewPageWidth(self) * kTOPagingViewPageSlotCount; 299 | _scrollView.contentSize = contentSize; 300 | } 301 | 302 | - (void)_resetContentOffset TOPAGINGVIEW_OBJC_DIRECT 303 | { 304 | if (_currentPageView == nil) { return; } 305 | 306 | // Reset the scroll view offset to the current page view 307 | CGPoint offset = CGPointZero; 308 | offset.x = CGRectGetMinX(_currentPageView.frame); 309 | offset.x -= (_pageSpacing * 0.5f); 310 | _scrollView.contentOffset = offset; 311 | } 312 | 313 | - (void)observeValueForKeyPath:(NSString *)keyPath 314 | ofObject:(id)object 315 | change:(NSDictionary *)change 316 | context:(void *)context 317 | { 318 | if ((__bridge id)context != self) { 319 | [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; 320 | } else if (!_disableLayout) { 321 | TOPagingViewLayoutPages(self); 322 | } 323 | } 324 | 325 | - (void)_layoutPages TOPAGINGVIEW_OBJC_DIRECT 326 | { 327 | // Since `TOPagingViewLayoutPages` is inlined, the only call to it should be 328 | // in the KVO callback method. For all other calls (That don't require frame-tick precision), 329 | // we can proxy through this method to call it. 330 | [self observeValueForKeyPath:nil ofObject:nil change:nil context:(__bridge void *)self]; 331 | } 332 | 333 | #pragma mark - Page Setup - 334 | 335 | - (void)registerPageViewClass:(Class)pageViewClass 336 | { 337 | NSAssert([pageViewClass isSubclassOfClass:[UIView class]], 338 | @"Only UIView objects may be registered as pages."); 339 | 340 | // Cache the protocol methods this class implements to save checking each time 341 | TOPagingViewCachedProtocolFlagsForPageViewClass(self, pageViewClass); 342 | 343 | // Fetch the page identifier (or use the default if none were 344 | const NSString *pageIdentifier = TOPagingViewIdentifierForPageViewClass(self, pageViewClass); 345 | 346 | // Lazily make the store for the first time 347 | if (_registeredPageViewClasses == nil) { 348 | _registeredPageViewClasses = [NSMutableDictionary dictionary]; 349 | } 350 | 351 | // Encode the class as an NSValue and store to the dictionary 352 | _registeredPageViewClasses[pageIdentifier] = TOPagingViewValueForClass(&pageViewClass); 353 | } 354 | 355 | - (__kindof UIView *)dequeueReusablePageView 356 | { 357 | return [self dequeueReusablePageViewForIdentifier:nil]; 358 | } 359 | 360 | - (__kindof UIView *)dequeueReusablePageViewForIdentifier:(NSString *)identifier 361 | { 362 | if (identifier.length == 0) { identifier = kTOPagingViewDefaultIdentifier; } 363 | 364 | // Fetch the set for this page type, and lazily create if it doesn't exist 365 | NSMutableSet *enqueuedPages = _queuedPages[identifier]; 366 | if (enqueuedPages == nil) { 367 | enqueuedPages = [NSMutableSet set]; 368 | _queuedPages[identifier] = enqueuedPages; 369 | } 370 | 371 | // Attempt to fetch a previous page from it 372 | UIView *pageView = enqueuedPages.anyObject; 373 | 374 | // If a page was found, set its bounds, and return it 375 | if (pageView) { 376 | if (!CGSizeEqualToSize(pageView.frame.size, self.bounds.size)) { 377 | pageView.frame = self.bounds; 378 | } 379 | return pageView; 380 | } 381 | 382 | // If we have a class for this one, create a new instance and return 383 | NSValue *pageClassValue = _registeredPageViewClasses[identifier]; 384 | if (pageClassValue) { 385 | Class pageClass = TOPagingViewClassForValue(pageClassValue); 386 | pageView = [[pageClass alloc] initWithFrame:self.bounds]; 387 | [enqueuedPages addObject:pageView]; 388 | return pageView; 389 | } 390 | 391 | return nil; 392 | } 393 | 394 | static inline NSString *TOPagingViewIdentifierForPageViewClass(TOPagingView *view, Class pageViewClass) 395 | { 396 | TOPageViewProtocolFlags flags = TOPagingViewCachedProtocolFlagsForPageViewClass(view, pageViewClass); 397 | if (flags.protocolPageIdentifier) { 398 | return [pageViewClass pageIdentifier]; 399 | } else { 400 | return kTOPagingViewDefaultIdentifier; 401 | } 402 | } 403 | 404 | static inline BOOL TOPagingViewIsInitialPageForPageView(TOPagingView *view, UIView *pageView) 405 | { 406 | if (pageView == nil) { return NO; } 407 | TOPageViewProtocolFlags flags = TOPagingViewCachedProtocolFlagsForPageViewClass(view, pageView.class); 408 | return flags.protocolIsInitialPage ? [pageView isInitialPage] : NO; 409 | } 410 | 411 | static inline void TOPagingViewSetPageDirectionForPageView(TOPagingView *view, TOPagingViewDirection direction, UIView *pageView) 412 | { 413 | if (pageView == nil) { return; } 414 | TOPageViewProtocolFlags flags = TOPagingViewCachedProtocolFlagsForPageViewClass(view, pageView.class); 415 | if (flags.protocolSetPageDirection) { [pageView setPageDirection:direction]; } 416 | } 417 | 418 | static inline TOPageViewProtocolFlags TOPagingViewCachedProtocolFlagsForPageViewClass(TOPagingView *view, Class class) 419 | { 420 | // Skip if we already captured the protocols from this class 421 | TOPageViewProtocolCache *cache = view->_pageViewProtocolFlags[NSStringFromClass(class)]; 422 | if (cache != nil) { return cache.flags; } 423 | 424 | // Create a new instance of the struct and prepare its memory 425 | TOPageViewProtocolFlags flags; 426 | memset(&flags, 0, sizeof(TOPageViewProtocolFlags)); 427 | 428 | // Capture the protocol methods this class implements 429 | flags.protocolPageIdentifier = [class respondsToSelector:@selector(pageIdentifier)]; 430 | flags.protocolUniqueIdentifier = [class instancesRespondToSelector:@selector(uniqueIdentifier)]; 431 | flags.protocolPrepareForReuse = [class instancesRespondToSelector:@selector(prepareForReuse)]; 432 | flags.protocolIsInitialPage = [class instancesRespondToSelector:@selector(isInitialPage)]; 433 | flags.protocolSetPageDirection = [class instancesRespondToSelector:@selector(setPageDirection:)]; 434 | 435 | // Store in the dictionary 436 | cache = [TOPageViewProtocolCache new]; 437 | cache.flags = flags; 438 | view->_pageViewProtocolFlags[NSStringFromClass(class)] = cache; 439 | 440 | // Return the flags 441 | return flags; 442 | } 443 | 444 | #pragma mark - External Page Control - 445 | 446 | - (void)reload 447 | { 448 | // Remove all currently visible pages from the scroll views 449 | for (UIView *view in _scrollView.subviews) { 450 | TOPagingViewReclaimPageView(self, view); 451 | [view removeFromSuperview]; 452 | } 453 | 454 | // Reset all of the active page references 455 | _currentPageView = nil; 456 | _previousPageView = nil; 457 | _nextPageView = nil; 458 | 459 | // Clean out all of the pages in the queues 460 | [_queuedPages removeAllObjects]; 461 | 462 | // Reset the content size of the scroll view content 463 | TOPagingViewPerformBlockWithoutLayout(self, ^{ 464 | self->_scrollView.contentSize = CGSizeZero; 465 | }); 466 | 467 | // Perform a fresh layout 468 | [self _layoutPages]; 469 | } 470 | 471 | - (void)reloadAdjacentPages { 472 | // Reclaim the previous and next pages 473 | TOPagingViewReclaimPageView(self, _nextPageView); 474 | TOPagingViewReclaimPageView(self, _previousPageView); 475 | 476 | _nextPageView = nil; 477 | _previousPageView = nil; 478 | 479 | _hasNextPage = YES; 480 | _hasPreviousPage = YES; 481 | 482 | [self _fetchNewNextPage]; 483 | if (!_isDynamicPageDirectionEnabled || !TOPagingViewIsInitialPageForPageView(self, _currentPageView)) { 484 | [self _fetchNewPreviousPage]; 485 | } else { 486 | _hasPreviousPage = _hasNextPage; 487 | } 488 | } 489 | 490 | - (void)fetchAdjacentPagesIfAvailable 491 | { 492 | if (_dataSource == nil) { return; } 493 | 494 | // If there currently isn't a previous page, check again to see if there is one now. 495 | if (!_hasPreviousPage) { 496 | UIView *previousPage = [_dataSource pagingView:self 497 | pageViewForType:TOPagingViewPageTypePrevious 498 | currentPageView:_currentPageView]; 499 | // Add the page view to the hierarchy 500 | if (previousPage) { 501 | TOPagingViewInsertPageView(self, previousPage); 502 | previousPage.frame = TOPagingViewPreviousPageFrame(self); 503 | _previousPageView = previousPage; 504 | _hasPreviousPage = YES; 505 | } 506 | } 507 | 508 | // If there currently isn't a next page, check again 509 | if (!_hasNextPage) { 510 | UIView *nextPage = [_dataSource pagingView:self 511 | pageViewForType:TOPagingViewPageTypeNext 512 | currentPageView:_currentPageView]; 513 | // Add the page view to the hierarchy 514 | if (nextPage) { 515 | TOPagingViewInsertPageView(self, nextPage); 516 | nextPage.frame = TOPagingViewNextPageFrame(self); 517 | _nextPageView = nextPage; 518 | _hasNextPage = YES; 519 | } 520 | } 521 | 522 | // If we're on the initial page, set the previous page state to match whatever the next state is 523 | if (_isDynamicPageDirectionEnabled && TOPagingViewIsInitialPageForPageView(self, _currentPageView)) { 524 | _hasPreviousPage = _hasNextPage; 525 | } 526 | 527 | [self _layoutPages]; 528 | } 529 | 530 | - (void)turnToNextPageAnimated:(BOOL)animated 531 | { 532 | if (TOPagingViewIsDirectionReversed(self)) { 533 | [self turnToLeftPageAnimated:animated]; 534 | } else { 535 | [self turnToRightPageAnimated:animated]; 536 | } 537 | } 538 | 539 | - (void)turnToPreviousPageAnimated:(BOOL)animated 540 | { 541 | if (TOPagingViewIsDirectionReversed(self)) { 542 | [self turnToRightPageAnimated:animated]; 543 | } else { 544 | [self turnToLeftPageAnimated:animated]; 545 | } 546 | } 547 | 548 | - (void)turnToLeftPageAnimated:(BOOL)animated 549 | { 550 | const BOOL isDirectionReversed = TOPagingViewIsDirectionReversed(self); 551 | const BOOL hasLeftPage = (isDirectionReversed && _hasNextPage) || 552 | (!isDirectionReversed && _hasPreviousPage); 553 | 554 | // Play a bouncy animation if there's no incoming page 555 | if (!hasLeftPage) { 556 | if (!animated) { return; } 557 | [self _playBounceAnimationInDirection:TOPagingViewDirectionRightToLeft]; 558 | return; 559 | } 560 | 561 | // Turn to the left side page 562 | [self _turnToPageInDirection:UIRectEdgeLeft animated:animated]; 563 | } 564 | 565 | - (void)turnToRightPageAnimated:(BOOL)animated 566 | { 567 | const BOOL isDirectionReversed = TOPagingViewIsDirectionReversed(self); 568 | const BOOL hasRightPage = (isDirectionReversed && _hasPreviousPage) || 569 | (!isDirectionReversed && _hasNextPage); 570 | 571 | // Play a bouncy animation if there's no incoming page 572 | if (!hasRightPage) { 573 | if (!animated) { return; } 574 | [self _playBounceAnimationInDirection:TOPagingViewDirectionLeftToRight]; 575 | return; 576 | } 577 | 578 | // Turn to the right side page 579 | [self _turnToPageInDirection:UIRectEdgeRight animated:animated]; 580 | } 581 | 582 | - (void)skipForwardToNewPageAnimated:(BOOL)animated 583 | { 584 | UIRectEdge direction = TOPagingViewIsDirectionReversed(self) ? UIRectEdgeLeft : UIRectEdgeRight; 585 | [self _skipToNewPageInDirection:direction animated:animated]; 586 | } 587 | 588 | - (void)skipBackwardToNewPageAnimated:(BOOL)animated 589 | { 590 | UIRectEdge direction = TOPagingViewIsDirectionReversed(self) ? UIRectEdgeRight : UIRectEdgeLeft; 591 | [self _skipToNewPageInDirection:direction animated:animated]; 592 | } 593 | 594 | #pragma mark - Page Layout & Management - 595 | 596 | static inline void TOPagingViewLayoutPages(TOPagingView *view) { 597 | // Only perform this overhead when we are in the appropriate state, 598 | // and we're not being disabled by an active animation. 599 | if (view->_dataSource == nil || view->_disableLayout) { return; } 600 | 601 | const CGSize contentSize = view->_scrollView.contentSize; 602 | 603 | // On first run, set up the initial pages layout 604 | if (view->_currentPageView == nil || contentSize.width < FLT_EPSILON) { 605 | TOPagingViewPerformInitialLayout(view); 606 | return; 607 | } 608 | 609 | // When dynamic paging is enabled, we swap the on-screen 'next' page to either 610 | // side of the initial page as the user swipes left and right 611 | if (view->_isDynamicPageDirectionEnabled 612 | && TOPagingViewIsInitialPageForPageView(view, view->_currentPageView)) { 613 | TOPagingViewHandleDynamicPageDirectionLayout(view); 614 | } 615 | 616 | // Check the offset of the scroll view, and when it passes over 617 | // the mid point between two pages, perform the page transition 618 | TOPagingViewHandlePageTransitions(view); 619 | 620 | // Observe user interaction for triggering certain delegate callbacks 621 | TOPagingViewUpdateDragInteractions(view); 622 | 623 | // When the page offset crosses either the left or right threshold, 624 | // check if a page is ready or not and enable insetting at that point to 625 | // avoid any hitchy motion 626 | TOPagingViewUpdateEnabledPages(view); 627 | } 628 | 629 | static inline void TOPagingViewPerformInitialLayout(TOPagingView *view) 630 | { 631 | // Set these back to true for now, since we'll perform the check in here 632 | view->_hasNextPage = YES; 633 | view->_hasPreviousPage = YES; 634 | 635 | // Send a delegate event stating we're about to transition to the initial page 636 | if (view->_delegateFlags.delegateWillTurnToPage) { 637 | [view->_delegate pagingView:view willTurnToPageOfType:TOPagingViewPageTypeCurrent]; 638 | } 639 | 640 | // Add the initial page 641 | UIView *pageView = [view->_dataSource pagingView:view 642 | pageViewForType:TOPagingViewPageTypeCurrent 643 | currentPageView:nil]; 644 | if (pageView == nil) { return; } 645 | view->_currentPageView = pageView; 646 | TOPagingViewInsertPageView(view, pageView); 647 | view->_currentPageView.frame = TOPagingViewCurrentPageFrame(view); 648 | 649 | // Add the next & previous pages 650 | [view _fetchNewNextPage]; 651 | 652 | // When dynamic page detection is enabled, skip fetching the previous page, and assume we have one if we have 653 | // a next page available. 654 | if (!view->_isDynamicPageDirectionEnabled || !TOPagingViewIsInitialPageForPageView(view, view->_currentPageView)) { 655 | [view _fetchNewPreviousPage]; 656 | } else { 657 | view->_hasPreviousPage = view->_hasNextPage; 658 | } 659 | 660 | // Disable the observer while we manually place all elements 661 | view->_disableLayout = YES; 662 | 663 | // Update the content size for the scroll view 664 | [view _updateContentSize]; 665 | 666 | // Set the initial scroll point to the current page 667 | [view _resetContentOffset]; 668 | 669 | // Re-enable the observer 670 | view->_disableLayout = NO; 671 | 672 | // Send a delegate event stating we've completed transitioning to the initial page 673 | if (view->_delegateFlags.delegateDidTurnToPage) { 674 | [view->_delegate pagingView:view didTurnToPageOfType:TOPagingViewPageTypeCurrent]; 675 | } 676 | } 677 | 678 | static inline void TOPagingViewPerformBlockWithoutLayout(TOPagingView *view, void (^block)(void)) 679 | { 680 | view->_disableLayout = YES; 681 | if (block) { block(); } 682 | view->_disableLayout = NO; 683 | } 684 | 685 | static inline void TOPagingViewHandleDynamicPageDirectionLayout(TOPagingView *view) 686 | { 687 | const CGPoint offset = view->_scrollView.contentOffset; 688 | const CGFloat segmentWidth = TOPagingViewScrollViewPageWidth(view); 689 | const UIView *nextPage = view->_nextPageView; 690 | const CGFloat xPosition = CGRectGetMinX(view->_nextPageView.frame); 691 | 692 | // Check when the page starts moving in a certain direction and update the 'next' 693 | // page to match if it hasn't already been updated. 694 | if (offset.x < segmentWidth - FLT_EPSILON && xPosition > segmentWidth) { 695 | TOPagingViewSetPageDirectionForPageView(view, TOPagingViewDirectionRightToLeft, view->_nextPageView); 696 | nextPage.frame = TOPagingViewLeftPageFrame(view); 697 | } else if (offset.x > segmentWidth + FLT_EPSILON && xPosition < segmentWidth) { 698 | TOPagingViewSetPageDirectionForPageView(view, TOPagingViewDirectionLeftToRight, view->_nextPageView); 699 | nextPage.frame = TOPagingViewRightPageFrame(view); 700 | } 701 | 702 | // If we've sufficiently committed to this direction, update the hosting paging view's direction 703 | BOOL needsDelegateUpdate = NO; 704 | if (offset.x <= FLT_EPSILON 705 | && view->_pageScrollDirection == TOPagingViewDirectionLeftToRight) { // Scrolled all the way to the left 706 | view->_pageScrollDirection = TOPagingViewDirectionRightToLeft; 707 | needsDelegateUpdate = YES; 708 | } else if (offset.x >= (segmentWidth * 2.0f) - FLT_EPSILON 709 | && view->_pageScrollDirection == TOPagingViewDirectionRightToLeft) { // Scrolled all the way to the right 710 | view->_pageScrollDirection = TOPagingViewDirectionLeftToRight; 711 | needsDelegateUpdate = YES; 712 | } 713 | 714 | if (needsDelegateUpdate && view->_delegateFlags.delegateDidChangeToPageDirection) { 715 | [view->_delegate pagingView:view didChangeToPageDirection:view->_pageScrollDirection]; 716 | } 717 | } 718 | 719 | static inline void TOPagingViewHandlePageTransitions(TOPagingView *view) 720 | { 721 | const BOOL isReversed = (view->_pageScrollDirection == TOPagingViewDirectionRightToLeft); 722 | const CGPoint offset = view->_scrollView.contentOffset; 723 | const CGFloat segmentWidth = TOPagingViewScrollViewPageWidth(view); 724 | const CGSize contentSize = view->_scrollView.contentSize; 725 | 726 | // Check if we went over the right-hand threshold to start transitioning the pages 727 | if ((!isReversed && offset.x >= (contentSize.width - segmentWidth)) 728 | || (isReversed && offset.x <= FLT_EPSILON)) { 729 | TOPagingViewTransitionOverToNextPage(view); 730 | } else if ((isReversed && offset.x >= (contentSize.width - segmentWidth)) 731 | || (!isReversed && offset.x <= FLT_EPSILON)) { // Check if we're over the left threshold 732 | TOPagingViewTransitionOverToPreviousPage(view); 733 | } 734 | } 735 | 736 | static inline void TOPagingViewUpdateDragInteractions(TOPagingView *view) 737 | { 738 | // Exit out if we don't actually use the delegate 739 | if (view->_delegateFlags.delegateWillTurnToPage == NO) { return; } 740 | 741 | // If we're not being dragged, reset the state 742 | if (view->_scrollView.isTracking == NO) { 743 | view->_draggingDirectionType = TOPagingViewPageTypeCurrent; 744 | view->_draggingOrigin = -CGFLOAT_MAX; 745 | return; 746 | } 747 | 748 | // If we just started dragging, capture the current offset and exit 749 | if (view->_draggingOrigin <= -CGFLOAT_MAX + FLT_EPSILON) { 750 | view->_draggingOrigin = view->_scrollView.contentOffset.x; 751 | return; 752 | } 753 | 754 | // Check the direction of the next step 755 | const CGFloat offset = view->_scrollView.contentOffset.x; 756 | const BOOL isDetectingDirection = (view->_isDynamicPageDirectionEnabled 757 | && TOPagingViewIsInitialPageForPageView(view, view->_currentPageView)); 758 | const BOOL isReversed = (view->_pageScrollDirection == TOPagingViewDirectionRightToLeft); 759 | TOPagingViewPageType directionType; 760 | 761 | //If we're detecting the direction, it will be 'next' regardless 762 | if (isDetectingDirection) { 763 | directionType = TOPagingViewPageTypeNext; 764 | } else if (offset < view->_draggingOrigin - FLT_EPSILON) { // We dragged to the right 765 | directionType = isReversed ? TOPagingViewPageTypeNext : TOPagingViewPageTypePrevious; 766 | } else if (offset > view->_draggingOrigin + FLT_EPSILON) { // We dragged to the left 767 | directionType = isReversed ? TOPagingViewPageTypePrevious : TOPagingViewPageTypeNext; 768 | } else { return; } 769 | 770 | // If this is a new direction than before, inform the delegate, and then save to avoid repeating 771 | if (directionType != view->_draggingDirectionType) { 772 | // Offload this delegate call to another run-loop to avoid any heavy operations as the data source 773 | [view->_delegate pagingView:view willTurnToPageOfType:directionType]; 774 | view->_draggingDirectionType = directionType; 775 | } 776 | 777 | // Update with the new offset 778 | view->_draggingOrigin = offset; 779 | } 780 | 781 | static inline void TOPagingViewUpdateEnabledPages(TOPagingView *view) 782 | { 783 | const CGPoint offset = view->_scrollView.contentOffset; 784 | const CGFloat segmentWidth = TOPagingViewScrollViewPageWidth(view); 785 | const BOOL isReversed = (view->_pageScrollDirection == TOPagingViewDirectionRightToLeft); 786 | 787 | // Check the offset and disable the adjancent slot if we've gone over the threshold 788 | BOOL isEnabled = NO; 789 | UIRectEdge edge = UIRectEdgeNone; 790 | if (offset.x < segmentWidth) { // Check the left page slot 791 | isEnabled = isReversed ? view->_hasNextPage : view->_hasPreviousPage; 792 | edge = UIRectEdgeLeft; 793 | } else if (offset.x > segmentWidth) { // Check the right slot 794 | isEnabled = isReversed ? view->_hasPreviousPage : view->_hasNextPage; 795 | edge = UIRectEdgeRight; 796 | } 797 | 798 | // If we matched and edge, update its state 799 | if (edge != UIRectEdgeNone) { 800 | TOPagingViewSetPageSlotEnabled(view, isEnabled, edge); 801 | } 802 | } 803 | 804 | static inline void TOPagingViewSetPageSlotEnabled(TOPagingView *view, BOOL enabled, UIRectEdge edge) 805 | { 806 | // Fetch the segment width. It will be used for either value 807 | const CGFloat segmentWidth = TOPagingViewScrollViewPageWidth(view); 808 | 809 | // Get the current insets of the scroll view 810 | UIEdgeInsets insets = view->_scrollView.contentInset; 811 | 812 | // Exit out if we don't need to set the state already 813 | const BOOL isLeft = (edge == UIRectEdgeLeft); 814 | CGFloat inset = isLeft ? insets.left : insets.right; 815 | if (enabled && inset == segmentWidth) { return; } 816 | else if (!enabled && inset == -segmentWidth) { return; } 817 | 818 | // When the slot is enabled, expand the scrollable region an 819 | // extra slot, so that it won't bump against the edge of the 820 | // scroll region when scrolling rapidly. 821 | // Otherwise, inset it a whole slot to disable it completely. 822 | CGFloat value = enabled ? segmentWidth : -segmentWidth; 823 | 824 | // Capture the content offset since changing the inset will change it 825 | CGPoint contentOffset = view->_scrollView.contentOffset; 826 | 827 | // Set the target inset value 828 | if (isLeft) { insets.left = value; } 829 | else { insets.right = value; } 830 | 831 | // Set the inset and then restore the offset 832 | view->_disableLayout = YES; 833 | view->_scrollView.contentInset = insets; 834 | view->_scrollView.contentOffset = contentOffset; 835 | view->_disableLayout = NO; 836 | } 837 | 838 | #pragma mark - Animated Transitions - 839 | 840 | - (void)_turnToPageInDirection:(UIRectEdge)direction animated:(BOOL)animated TOPAGINGVIEW_OBJC_DIRECT 841 | { 842 | // Determine the direction to animate towards 843 | CGFloat offset = 0.0f; 844 | if (direction == UIRectEdgeRight) { 845 | offset = _scrollView.contentSize.width - TOPagingViewScrollViewPageWidth(self); 846 | } 847 | 848 | UIScrollView *const scrollView = _scrollView; 849 | 850 | // Determine the direction we're heading for the delegate 851 | const BOOL isLeftDirection = (offset < FLT_EPSILON); 852 | const BOOL isDirectionReversed = TOPagingViewIsDirectionReversed(self); 853 | const BOOL isDetectingDirection = _isDynamicPageDirectionEnabled 854 | && TOPagingViewIsInitialPageForPageView(self, _currentPageView); 855 | const BOOL isPreviousPage = !isDetectingDirection && ((!isDirectionReversed && isLeftDirection) || 856 | (isDirectionReversed && !isLeftDirection)); 857 | 858 | // Send a delegate event stating the page is about to turn 859 | if (_delegateFlags.delegateWillTurnToPage) { 860 | TOPagingViewPageType type = (isPreviousPage ? TOPagingViewPageTypePrevious : TOPagingViewPageTypeNext); 861 | [_delegate pagingView:self willTurnToPageOfType:type]; 862 | } 863 | 864 | // If we're not animating, re-enable layout, 865 | // and then set the offset to the target 866 | if (animated == NO) { 867 | scrollView.contentOffset = (CGPoint){offset, 0.0f}; 868 | return; 869 | } 870 | 871 | // If the scroll view delegate was set, define this block we can use when the animations completes. 872 | // (Whether it succeeded, or got canceled) 873 | __weak __typeof(self) weakSelf = self; 874 | void (^scrollDidEndDelegateBlock)(void) = ^{ 875 | __strong __typeof(self) strongSelf = weakSelf; 876 | if (strongSelf == nil) { return; } 877 | id scrollViewDelegate = strongSelf->_scrollView.delegate; 878 | if (scrollViewDelegate) { 879 | if ([scrollViewDelegate respondsToSelector:@selector(scrollViewDidEndScrollingAnimation:)]) { 880 | [scrollViewDelegate scrollViewDidEndScrollingAnimation:strongSelf->_scrollView]; 881 | } 882 | } 883 | }; 884 | 885 | // If the scroll view is decelerating from a swipe, cancel it. 886 | if (scrollView.isDecelerating) { 887 | [scrollView setContentOffset:scrollView.contentOffset animated:NO]; 888 | } 889 | 890 | // If we're already in an animation, we can't queue up a new animation 891 | // before the old one completes, otherwise we'll overshoot the pages and cause visual glitching. 892 | // (There might be a better way to implement this down the line) 893 | if (scrollView.layer.animationKeys.count) { 894 | // If we're already in an animation that is moving towards the last a page 895 | // with no page coming after it, cancel out to let the animation completely fluidly. 896 | if ((isPreviousPage && !_hasPreviousPage) || (!isPreviousPage && !_hasNextPage)) { 897 | return; 898 | } 899 | 900 | // Cancel the current animation 901 | [scrollView.layer removeAllAnimations]; 902 | 903 | // Trigger the completed delegate 904 | scrollDidEndDelegateBlock(); 905 | 906 | // Re-enable layout so when we change the content offset, we'll reset all of the pages 907 | _disableLayout = NO; 908 | } 909 | 910 | // Move the scroll view to the target offset, which will trigger a layout. 911 | // This will update all of the page views, and trigger the delegate with the right state 912 | scrollView.contentOffset = (CGPoint){offset, 0.0f}; 913 | 914 | // Disable layout during this animation as we'll manually control layout here 915 | _disableLayout = YES; 916 | 917 | // The scroll view will now be centered, so lets capture this destination 918 | const CGPoint destOffset = scrollView.contentOffset; 919 | 920 | // Move the scroll view back to where it should be so we can perform the animation 921 | if (offset < FLT_EPSILON) { 922 | scrollView.contentOffset = (CGPoint){TOPagingViewScrollViewPageWidth(self) * 2.0f, 0.0f}; 923 | } else { 924 | scrollView.contentOffset = (CGPoint){0.0f, 0.0f}; 925 | } 926 | 927 | // Define animation parameters 928 | id animationBlock = ^{ 929 | __strong __typeof(weakSelf) strongSelf = weakSelf; 930 | if (!strongSelf) { return; } 931 | strongSelf->_scrollView.contentOffset = destOffset; 932 | }; 933 | 934 | id completionBlock = ^(UIViewAnimatingPosition finalPosition) { 935 | __strong __typeof(weakSelf) strongSelf = weakSelf; 936 | if (!strongSelf) { return; } 937 | 938 | // Re-enable automatic layout 939 | strongSelf->_disableLayout = NO; 940 | 941 | // Perform a sanity layout just in case 942 | // (But in most cases, this should be a no-op) 943 | [strongSelf _layoutPages]; 944 | 945 | // Trigger the animation completed delgate 946 | scrollDidEndDelegateBlock(); 947 | }; 948 | 949 | // Perform a very tight transition animation to the next page 950 | [_pageViewAnimator addAnimations:animationBlock]; 951 | [_pageViewAnimator addCompletion:completionBlock]; 952 | [_pageViewAnimator startAnimation]; 953 | } 954 | 955 | - (void)_skipToNewPageInDirection:(UIRectEdge)direction animated:(BOOL)animated TOPAGINGVIEW_OBJC_DIRECT 956 | { 957 | // Disable the layout since we'll handle everything beyond this point 958 | _disableLayout = YES; 959 | 960 | // If the scroll view is decelerating from a swipe, cancel it. 961 | if (_scrollView.isDecelerating) { 962 | [_scrollView setContentOffset:_scrollView.contentOffset animated:NO]; 963 | } 964 | 965 | // If we're already in an animation, cancel it and reset the position 966 | if (_scrollView.layer.animationKeys.count > 0) { 967 | [_scrollView.layer removeAllAnimations]; 968 | } 969 | 970 | // Reclaim the next and previous pages since these will always need to be regenerated 971 | TOPagingViewReclaimPageView(self, _nextPageView); 972 | TOPagingViewReclaimPageView(self, _previousPageView); 973 | 974 | // Request the new page view that will become the new current page after this completes 975 | UIView *newPageView = [_dataSource pagingView:self 976 | pageViewForType:TOPagingViewPageTypeCurrent 977 | currentPageView:_currentPageView]; 978 | 979 | // Set the destination point regardless of animation to them middle 980 | CGPoint destinationPoint = (CGPoint){TOPagingViewScrollViewPageWidth(self), 0.0f}; // Destination is always the middle 981 | 982 | // Zero out the adjacent pages and set the 983 | // next/previous flags to ensure we'll query for new pages 984 | _nextPageView = nil; 985 | _previousPageView = nil; 986 | _hasNextPage = NO; 987 | _hasPreviousPage = NO; 988 | 989 | // If we're not animating, we can rearrange everything statically and cancel out here 990 | if (!animated) { 991 | // Reclaim the current page since we'll swap over to the newly requested one 992 | TOPagingViewReclaimPageView(self, _currentPageView); 993 | 994 | // Insert the new current page view 995 | _currentPageView = newPageView; 996 | _currentPageView.frame = TOPagingViewCurrentPageFrame(self); 997 | TOPagingViewInsertPageView(self, _currentPageView); 998 | 999 | // Re-enable layout to trigger a check for the next pages 1000 | _disableLayout = NO; 1001 | 1002 | // Re-set the offset to the middle 1003 | _scrollView.contentOffset = destinationPoint; 1004 | 1005 | // Trigger requesting replacement adjacent pages 1006 | [self fetchAdjacentPagesIfAvailable]; 1007 | 1008 | return; 1009 | } 1010 | 1011 | // Set the scroll view offset to adjacent the middle to animate 1012 | _scrollView.contentOffset = (direction == UIRectEdgeLeft) ? (CGPoint){TOPagingViewScrollViewPageWidth(self) * 2.0f, 0.0f} : CGPointZero; 1013 | 1014 | // Put the current view in the same slot so we can animate to the new one 1015 | _currentPageView.frame = (direction == UIRectEdgeLeft) ? TOPagingViewRightPageFrame(self) : TOPagingViewLeftPageFrame(self); 1016 | 1017 | // Make the old current page the previous page so we can keep track of it across animations 1018 | _previousPageView = _currentPageView; 1019 | 1020 | // Put the new view in the center point and promote it to new current 1021 | _currentPageView = newPageView; 1022 | _currentPageView.frame = TOPagingViewCurrentPageFrame(self); 1023 | TOPagingViewInsertPageView(self, _currentPageView); 1024 | 1025 | // Define the animation block, making sure not to cause any retain cycles 1026 | __weak __typeof(self) weakSelf = self; 1027 | id animationBlock = ^{ 1028 | __strong __typeof(weakSelf) strongSelf = weakSelf; 1029 | if (!strongSelf) { return; } 1030 | strongSelf->_scrollView.contentOffset = destinationPoint; 1031 | }; 1032 | 1033 | // Define the completion block 1034 | id completionBlock = ^(UIViewAnimatingPosition finalPosition) { 1035 | __strong __typeof(weakSelf) strongSelf = weakSelf; 1036 | if (!strongSelf) { return; } 1037 | 1038 | // Remove the previous page 1039 | TOPagingViewReclaimPageView(self, self->_previousPageView); 1040 | strongSelf->_previousPageView = nil; 1041 | 1042 | // Re-enable layout 1043 | strongSelf->_disableLayout = NO; 1044 | 1045 | // Trigger requesting replacement adjacent pages 1046 | [strongSelf fetchAdjacentPagesIfAvailable]; 1047 | 1048 | // If the scroll view delegate was set, tell it the animation completed 1049 | id scrollViewDelegate = strongSelf->_scrollView.delegate; 1050 | if (scrollViewDelegate) { 1051 | if ([scrollViewDelegate respondsToSelector:@selector(scrollViewDidEndScrollingAnimation:)]) { 1052 | [scrollViewDelegate scrollViewDidEndScrollingAnimation:strongSelf->_scrollView]; 1053 | } 1054 | } 1055 | }; 1056 | 1057 | // Perform a very tight transition animation to the target page 1058 | [_pageViewAnimator addAnimations:animationBlock]; 1059 | [_pageViewAnimator addCompletion:completionBlock]; 1060 | [_pageViewAnimator startAnimation]; 1061 | } 1062 | 1063 | - (nullable __kindof UIView *)pageViewForUniqueIdentifier:(NSString *)identifier 1064 | { 1065 | return _uniqueIdentifierPages[identifier]; 1066 | } 1067 | 1068 | #pragma mark - Page View Recycling - 1069 | 1070 | static void TOPagingViewInsertPageView(TOPagingView *view, UIView *pageView) 1071 | { 1072 | if (pageView == nil) { return; } 1073 | 1074 | // Add the view to the scroll view 1075 | if (pageView.superview == nil) { [view->_scrollView addSubview:pageView]; } 1076 | pageView.hidden = NO; 1077 | 1078 | // Cache the page's protocol methods if it hasn't been done yet 1079 | TOPageViewProtocolFlags flags = TOPagingViewCachedProtocolFlagsForPageViewClass(view, pageView.class); 1080 | 1081 | // If it has a unique identifier, store it so we can refer to it easily 1082 | if (flags.protocolUniqueIdentifier) { 1083 | NSString *uniqueIdentifier = [(id)pageView uniqueIdentifier]; 1084 | 1085 | // Lazily create the dictionary as needed 1086 | if (view->_uniqueIdentifierPages == nil) { 1087 | view->_uniqueIdentifierPages = [NSMutableDictionary dictionary]; 1088 | } 1089 | 1090 | // Add to the dictionary 1091 | view->_uniqueIdentifierPages[uniqueIdentifier] = pageView; 1092 | } 1093 | 1094 | // If the page view supports it, inform the delegate of the current page direction 1095 | if (flags.protocolSetPageDirection) { 1096 | [pageView setPageDirection:view->_pageScrollDirection]; 1097 | } 1098 | 1099 | // Remove it from the pool of recycled pages 1100 | NSString *pageIdentifier = TOPagingViewIdentifierForPageViewClass(view, pageView.class); 1101 | [view->_queuedPages[pageIdentifier] removeObject:pageView]; 1102 | } 1103 | 1104 | static void TOPagingViewReclaimPageView(TOPagingView *view, UIView *pageView) 1105 | { 1106 | if (pageView == nil) { return; } 1107 | 1108 | // Skip internal UIScrollView views 1109 | if ([NSStringFromClass([pageView class]) characterAtIndex:0] == '_') { 1110 | return; 1111 | } 1112 | 1113 | // Fetch the protocol flags for this class 1114 | TOPageViewProtocolFlags flags = TOPagingViewCachedProtocolFlagsForPageViewClass(view, pageView.class); 1115 | 1116 | // If the page has a unique identifier, remove it from the dictionary 1117 | if (flags.protocolUniqueIdentifier) { 1118 | [view->_uniqueIdentifierPages removeObjectForKey:[(id)pageView uniqueIdentifier]]; 1119 | } 1120 | 1121 | // If the class supports the clean up method, clean it up now 1122 | if (flags.protocolPrepareForReuse) { 1123 | [(id)pageView prepareForReuse]; 1124 | } 1125 | 1126 | // Hide the view (Don't remove because that is a heavier operation) 1127 | pageView.hidden = YES; 1128 | 1129 | // Re-add it to the recycled pages pool 1130 | NSString *pageIdentifier = TOPagingViewIdentifierForPageViewClass(view, pageView.class); 1131 | [view->_queuedPages[pageIdentifier] addObject:pageView]; 1132 | } 1133 | 1134 | #pragma mark - Page Transitions - 1135 | 1136 | static inline void TOPagingViewTransitionOverToNextPage(TOPagingView *view) 1137 | { 1138 | // Don't start churning if we already confirmed there is no page after this. 1139 | if (!view->_hasNextPage) { return; } 1140 | 1141 | // If we moved over to the threshold of the next page, 1142 | // re-enable the previous page 1143 | if (!view->_hasPreviousPage) { 1144 | view->_hasPreviousPage = YES; 1145 | } 1146 | 1147 | view->_disableLayout = YES; 1148 | 1149 | // Reclaim the previous view 1150 | TOPagingViewReclaimPageView(view, view->_previousPageView); 1151 | 1152 | // Update all of the references by pushing each view back 1153 | view->_previousPageView = view->_currentPageView; 1154 | view->_currentPageView = view->_nextPageView; 1155 | view->_nextPageView = nil; 1156 | 1157 | // Update the frames of the pages 1158 | view->_currentPageView.frame = TOPagingViewCurrentPageFrame(view); 1159 | view->_previousPageView.frame = TOPagingViewPreviousPageFrame(view); 1160 | 1161 | // Inform the delegate we have comitted to a transition so we can update state for the next page 1162 | if (view->_delegateFlags.delegateDidTurnToPage) { 1163 | [view->_delegate pagingView:view didTurnToPageOfType:TOPagingViewPageTypeNext]; 1164 | } 1165 | 1166 | // Offload the heavy work to a new run-loop cyle so we don't overload the current one 1167 | view->_needsNextPage = YES; 1168 | [view setNeedsLayout]; 1169 | 1170 | // Move the scroll view back one segment 1171 | CGPoint contentOffset = view->_scrollView.contentOffset; 1172 | const CGFloat scrollViewPageWidth = TOPagingViewScrollViewPageWidth(view); 1173 | const BOOL isDirectionReversed = (view->_pageScrollDirection == TOPagingViewDirectionRightToLeft); 1174 | if (isDirectionReversed) { contentOffset.x += scrollViewPageWidth; } 1175 | else { contentOffset.x -= scrollViewPageWidth; } 1176 | TOPagingViewPerformBlockWithoutLayout(view, ^{ 1177 | view->_scrollView.contentOffset = contentOffset; 1178 | }); 1179 | 1180 | // If we're dragging, reset the state 1181 | if (view->_scrollView.isDragging) { 1182 | view->_draggingOrigin = -CGFLOAT_MAX; 1183 | } 1184 | 1185 | view->_disableLayout = NO; 1186 | } 1187 | 1188 | static inline void TOPagingViewTransitionOverToPreviousPage(TOPagingView *view) 1189 | { 1190 | // Don't start churning if we already confirmed there is no page before this. 1191 | if (!view->_hasPreviousPage) { return; } 1192 | 1193 | // If we confirmed we moved away from the next page, re-enable 1194 | // so we can query again next time 1195 | if (!view->_hasNextPage) { 1196 | view->_hasNextPage = YES; 1197 | } 1198 | 1199 | view->_disableLayout = YES; 1200 | 1201 | // Reclaim the next view 1202 | TOPagingViewReclaimPageView(view, view->_nextPageView); 1203 | 1204 | // Update all of the references by pushing each view forward 1205 | view->_nextPageView = view->_currentPageView; 1206 | view->_currentPageView = view->_previousPageView; 1207 | view->_previousPageView = nil; 1208 | 1209 | // Update the frames of the pages 1210 | view->_currentPageView.frame = TOPagingViewCurrentPageFrame(view); 1211 | view->_nextPageView.frame = TOPagingViewNextPageFrame(view); 1212 | 1213 | // Inform the delegate we have just committed to a transition so we can update state for the previous page 1214 | if (view->_delegateFlags.delegateDidTurnToPage) { 1215 | [view->_delegate pagingView:view didTurnToPageOfType:TOPagingViewPageTypePrevious]; 1216 | } 1217 | 1218 | // Offload the heavy work to a new run-loop cyle so we don't overload the current one 1219 | view->_needsPreviousPage = YES; 1220 | [view setNeedsLayout]; 1221 | 1222 | // Move the scroll view forward one segment 1223 | CGPoint contentOffset = view->_scrollView.contentOffset; 1224 | const CGFloat scrollViewPageWidth = TOPagingViewScrollViewPageWidth(view); 1225 | const BOOL isDirectionReversed = (view->_pageScrollDirection == TOPagingViewDirectionRightToLeft); 1226 | if (isDirectionReversed) { contentOffset.x -= TOPagingViewScrollViewPageWidth(view); } 1227 | else { contentOffset.x += scrollViewPageWidth; } 1228 | TOPagingViewPerformBlockWithoutLayout(view, ^{ 1229 | view->_scrollView.contentOffset = contentOffset; 1230 | }); 1231 | 1232 | // If we're dragging, reset the state 1233 | if (view->_scrollView.isDragging) { 1234 | view->_draggingOrigin = -CGFLOAT_MAX; 1235 | } 1236 | 1237 | view->_disableLayout = NO; 1238 | } 1239 | 1240 | - (void)_fetchNewNextPage TOPAGINGVIEW_OBJC_DIRECT 1241 | { 1242 | // Query the data source for the next page 1243 | UIView *nextPage = [_dataSource pagingView:self 1244 | pageViewForType:TOPagingViewPageTypeNext 1245 | currentPageView:_nextPageView]; 1246 | 1247 | if (nextPage) { 1248 | // Insert the new page object and update its position (Will fall through if nil) 1249 | TOPagingViewInsertPageView(self, nextPage); 1250 | _nextPageView = nextPage; 1251 | _nextPageView.frame = TOPagingViewNextPageFrame(self); 1252 | } 1253 | 1254 | // If the next page ended up being nil, 1255 | // set a flag to prevent churning, and inset the scroll inset 1256 | _hasNextPage = (nextPage != nil); 1257 | } 1258 | 1259 | - (void)_fetchNewPreviousPage TOPAGINGVIEW_OBJC_DIRECT 1260 | { 1261 | // Query the data source for the previous page, and exit out if there is no more page data 1262 | UIView *previousPage = [_dataSource pagingView:self 1263 | pageViewForType:TOPagingViewPageTypePrevious 1264 | currentPageView:_previousPageView]; 1265 | 1266 | if (previousPage) { 1267 | // Insert the new page object and set its position (Will fall through if nil) 1268 | TOPagingViewInsertPageView(self, previousPage); 1269 | _previousPageView = previousPage; 1270 | _previousPageView.frame = TOPagingViewPreviousPageFrame(self); 1271 | } 1272 | 1273 | // If the previous page ended up being nil, set a flag so we don't check again until we need to 1274 | _hasPreviousPage = (previousPage != nil); 1275 | } 1276 | 1277 | - (void)_rearrangePagesForScrollDirection:(TOPagingViewDirection)direction TOPAGINGVIEW_OBJC_DIRECT 1278 | { 1279 | // Left is for Eastern type layouts 1280 | const BOOL leftDirection = (direction == TOPagingViewDirectionRightToLeft); 1281 | 1282 | const CGFloat segmentWidth = TOPagingViewScrollViewPageWidth(self); 1283 | const CGFloat contentWidth = _scrollView.contentSize.width; 1284 | const CGFloat halfSpacing = self.pageSpacing * 0.5f; 1285 | const CGFloat rightOffset = (contentWidth - segmentWidth) + halfSpacing; 1286 | 1287 | // Move the next page to the left if direction is left, or vice versa 1288 | if (_nextPageView) { 1289 | CGRect frame = _nextPageView.frame; 1290 | if (leftDirection) { frame.origin.x = halfSpacing; } 1291 | else { frame.origin.x = rightOffset; } 1292 | _nextPageView.frame = frame; 1293 | } 1294 | 1295 | // Move the previous page to the right if direction is left, or vice versa 1296 | if (_previousPageView) { 1297 | CGRect frame = _previousPageView.frame; 1298 | if (leftDirection) { frame.origin.x = rightOffset; } 1299 | else { frame.origin.x = halfSpacing; } 1300 | _previousPageView.frame = frame; 1301 | } 1302 | 1303 | // Inform all of the pages that the direction changed, so they can re-arrange their subviews as needed 1304 | TOPagingViewSetPageDirectionForPageView(self, direction, _currentPageView); 1305 | TOPagingViewSetPageDirectionForPageView(self, direction, _nextPageView); 1306 | TOPagingViewSetPageDirectionForPageView(self, direction, _previousPageView); 1307 | 1308 | // Flip the content insets if we were potentially at the end of the scroll view 1309 | UIEdgeInsets insets = _scrollView.contentInset; 1310 | CGFloat leftInset = insets.left; 1311 | insets.left = insets.right; 1312 | insets.right = leftInset; 1313 | TOPagingViewPerformBlockWithoutLayout(self, ^{ 1314 | self->_scrollView.contentInset = insets; 1315 | }); 1316 | } 1317 | 1318 | - (void)_playBounceAnimationInDirection:(TOPagingViewDirection)direction TOPAGINGVIEW_OBJC_DIRECT 1319 | { 1320 | const CGFloat offsetModifier = (direction == TOPagingViewDirectionLeftToRight) ? 1.0f : -1.0f; 1321 | const BOOL isCompactSizeClass = self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassCompact; 1322 | const CGFloat bumperPadding = (isCompactSizeClass ? kTOPagingViewBumperWidthCompact : 1323 | kTOPagingViewBumperWidthRegular) * offsetModifier; 1324 | 1325 | // Set the origin and bumper margins 1326 | const CGPoint origin = (CGPoint){TOPagingViewScrollViewPageWidth(self), 0.0f}; 1327 | const CGPoint bumperOffset = (CGPoint){origin.x + bumperPadding, 0.0f}; 1328 | 1329 | // Disable layout while this is occurring 1330 | _disableLayout = YES; 1331 | 1332 | // Animation block when pulling back to the original state 1333 | void (^popAnimationBlock)(void) = ^{ 1334 | [self->_scrollView setContentOffset:origin animated:NO]; 1335 | }; 1336 | 1337 | // Completion block that cleans everything up at the end of the animation 1338 | void (^popAnimationCompletionBlock)(BOOL) = ^(BOOL success) { 1339 | self->_disableLayout = NO; 1340 | }; 1341 | 1342 | // Initial block that starts the animation chain 1343 | void (^pullAnimationBlock)(void) = ^{ 1344 | [self->_scrollView setContentOffset:bumperOffset animated:NO]; 1345 | }; 1346 | 1347 | // Completion block after the initial pull back is started 1348 | void (^pullAnimationCompletionBlock)(BOOL) = ^(BOOL success) { 1349 | // Play a very wobbly spring back animation snapping back into place 1350 | [UIView animateWithDuration:0.4f 1351 | delay:0.0f 1352 | usingSpringWithDamping:0.3f 1353 | initialSpringVelocity:0.1f 1354 | options:kTOPagingViewAnimationOptions 1355 | animations:popAnimationBlock 1356 | completion:popAnimationCompletionBlock]; 1357 | }; 1358 | 1359 | // Kickstart the animation chain. 1360 | // Play a very quick rubber-banding slide out to the bumper padding 1361 | [UIView animateWithDuration:0.1f 1362 | delay:0.0f 1363 | usingSpringWithDamping:1.0f 1364 | initialSpringVelocity:2.5f 1365 | options:kTOPagingViewAnimationOptions 1366 | animations:pullAnimationBlock 1367 | completion:pullAnimationCompletionBlock]; 1368 | } 1369 | 1370 | - (void)_requestPendingPages TOPAGINGVIEW_OBJC_DIRECT 1371 | { 1372 | // Don't continue if neither pages are pending 1373 | if (!_needsNextPage && !_needsPreviousPage) { return; } 1374 | 1375 | // Request a new next page 1376 | if (_needsNextPage) { 1377 | // We shouldn't be in a state where a next page is already set, 1378 | // but re-use it if we do 1379 | if (_nextPageView != nil) { 1380 | TOPagingViewInsertPageView(self, _nextPageView); 1381 | } else { 1382 | [self _fetchNewNextPage]; 1383 | } 1384 | 1385 | // Reset the state 1386 | _needsNextPage = NO; 1387 | 1388 | // If we also have a previous page, offload that to another tick 1389 | if (_needsPreviousPage) { 1390 | [self setNeedsLayout]; 1391 | return; 1392 | } 1393 | } 1394 | 1395 | // If we have dynamic page detection, and we're on the origin page, 1396 | // don't request a previous page since we're re-using just the next page. 1397 | if (_isDynamicPageDirectionEnabled && TOPagingViewIsInitialPageForPageView(self, _currentPageView)) { 1398 | _needsPreviousPage = NO; 1399 | return; 1400 | } 1401 | 1402 | // Request a new previous page 1403 | if (_needsPreviousPage) { 1404 | // We shouldn't be in a state where a previous page is already set, 1405 | // but re-use it if we do 1406 | if (_previousPageView != nil) { 1407 | TOPagingViewInsertPageView(self, _previousPageView); 1408 | } else { 1409 | [self _fetchNewPreviousPage]; 1410 | } 1411 | 1412 | // Reset the state 1413 | _needsPreviousPage = NO; 1414 | 1415 | // If we also have a next page, offload that to another tick 1416 | if (_needsNextPage) { 1417 | [self setNeedsLayout]; 1418 | return; 1419 | } 1420 | } 1421 | } 1422 | 1423 | #pragma mark - Keyboard Control - 1424 | 1425 | - (BOOL)canBecomeFirstResponder { return YES; } 1426 | 1427 | - (NSArray *)keyCommands 1428 | { 1429 | SEL selector = @selector(arrowKeyPressed:); 1430 | UIKeyCommand *leftArrowCommand = [UIKeyCommand keyCommandWithInput:UIKeyInputLeftArrow 1431 | modifierFlags:0 1432 | action:selector]; 1433 | 1434 | UIKeyCommand *rightArrowCommand = [UIKeyCommand keyCommandWithInput:UIKeyInputRightArrow 1435 | modifierFlags:0 1436 | action:selector]; 1437 | 1438 | if (@available(iOS 15.0, *)) { 1439 | leftArrowCommand.wantsPriorityOverSystemBehavior = YES; 1440 | rightArrowCommand.wantsPriorityOverSystemBehavior = YES; 1441 | } 1442 | 1443 | return @[leftArrowCommand, rightArrowCommand]; 1444 | } 1445 | 1446 | - (void)arrowKeyPressed:(UIKeyCommand *)command 1447 | { 1448 | if ([command.input isEqualToString:UIKeyInputLeftArrow]) { 1449 | [self turnToLeftPageAnimated:YES]; 1450 | } 1451 | else if ([command.input isEqualToString:UIKeyInputRightArrow]) { 1452 | [self turnToRightPageAnimated:YES]; 1453 | } 1454 | } 1455 | 1456 | #pragma mark - Public Accessors - 1457 | 1458 | - (void)setDataSource:(id)dataSource 1459 | { 1460 | if (dataSource == _dataSource) { return; } 1461 | _dataSource = dataSource; 1462 | if (self.superview) { [self reload]; } 1463 | } 1464 | 1465 | - (void)setDelegate:(id)delegate 1466 | { 1467 | if (delegate == _delegate) { return; } 1468 | _delegate = delegate; 1469 | _delegateFlags.delegateWillTurnToPage = [_delegate 1470 | respondsToSelector:@selector(pagingView:willTurnToPageOfType:)]; 1471 | _delegateFlags.delegateDidTurnToPage = [_delegate 1472 | respondsToSelector:@selector(pagingView:didTurnToPageOfType:)]; 1473 | _delegateFlags.delegateDidChangeToPageDirection = [_delegate 1474 | respondsToSelector:@selector(pagingView:didChangeToPageDirection:)]; 1475 | } 1476 | 1477 | - (nullable NSSet<__kindof UIView *> *)visiblePageViews 1478 | { 1479 | NSMutableSet *visiblePages = [NSMutableSet set]; 1480 | if (_previousPageView) { [visiblePages addObject:_previousPageView]; } 1481 | if (_currentPageView) { [visiblePages addObject:_currentPageView]; } 1482 | if (_nextPageView) { [visiblePages addObject:_nextPageView]; } 1483 | if (visiblePages.count == 0) { return nil; } 1484 | return [NSSet setWithSet:visiblePages]; 1485 | } 1486 | 1487 | - (void)setPageScrollDirection:(TOPagingViewDirection)pageScrollDirection 1488 | { 1489 | if (_pageScrollDirection == pageScrollDirection) { return; } 1490 | _pageScrollDirection = pageScrollDirection; 1491 | [self _rearrangePagesForScrollDirection:_pageScrollDirection]; 1492 | } 1493 | 1494 | - (void)setIsDynamicPageDirectionEnabled:(BOOL)isDynamicPageDirectionEnabled { 1495 | if (_isDynamicPageDirectionEnabled == isDynamicPageDirectionEnabled) { return; } 1496 | _isDynamicPageDirectionEnabled = isDynamicPageDirectionEnabled; 1497 | [self reload]; 1498 | } 1499 | 1500 | #pragma mark - Layout Calculation Helpers - 1501 | 1502 | static inline CGFloat TOPagingViewScrollViewPageWidth(TOPagingView *view) 1503 | { 1504 | return view.bounds.size.width + view->_pageSpacing; 1505 | } 1506 | 1507 | static inline BOOL TOPagingViewIsDirectionReversed(TOPagingView *view) 1508 | { 1509 | return (view->_pageScrollDirection == TOPagingViewDirectionRightToLeft); 1510 | } 1511 | 1512 | static inline CGRect TOPagingViewScrollViewFrame(TOPagingView *view) 1513 | { 1514 | const CGRect frame = CGRectInset(view.bounds, -(view->_pageSpacing * 0.5f), 0.0f); 1515 | return CGRectIntegral(frame); 1516 | } 1517 | 1518 | static inline CGRect TOPagingViewCurrentPageFrame(TOPagingView *view) 1519 | { 1520 | // Current page is always in the middle slot 1521 | return CGRectMake(TOPagingViewScrollViewPageWidth(view) + (view->_pageSpacing * 0.5f), 1522 | view.bounds.origin.y, 1523 | view.bounds.size.width, 1524 | view.bounds.size.height); 1525 | } 1526 | 1527 | static inline CGRect TOPagingViewNextPageFrame(TOPagingView *view) 1528 | { 1529 | // Next frame is on the right side when non-reversed, 1530 | // and on the right side when reversed 1531 | return TOPagingViewIsDirectionReversed(view) ? 1532 | TOPagingViewLeftPageFrame(view) : TOPagingViewRightPageFrame(view); 1533 | } 1534 | 1535 | static inline CGRect TOPagingViewPreviousPageFrame(TOPagingView *view) 1536 | { 1537 | // Previous frame is on the left side when non-reversed, 1538 | // and on the right side when reversed 1539 | return TOPagingViewIsDirectionReversed(view) ? 1540 | TOPagingViewRightPageFrame(view) : TOPagingViewLeftPageFrame(view); 1541 | } 1542 | 1543 | static inline CGRect TOPagingViewLeftPageFrame(TOPagingView *view) 1544 | { 1545 | return CGRectOffset(view.bounds, (view->_pageSpacing * 0.5f), 0.0f); 1546 | } 1547 | 1548 | static inline CGRect TOPagingViewRightPageFrame(TOPagingView *view) 1549 | { 1550 | return CGRectOffset(view.bounds, (TOPagingViewScrollViewPageWidth(view) * 2.0f) + (view->_pageSpacing * 0.5f), 0.0f); 1551 | } 1552 | 1553 | @end 1554 | -------------------------------------------------------------------------------- /TOPagingViewExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /TOPagingViewExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIRequiredDeviceCapabilities 26 | 27 | armv7 28 | 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /TOPagingViewExample/TOAppDelegate.h: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.h 3 | // TOPagingViewExample 4 | // 5 | // Created by Tim Oliver on 2020/03/23. 6 | // Copyright © 2020 Tim Oliver. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface TOAppDelegate : UIResponder 12 | 13 | @property (nonatomic, strong) UIWindow *window; 14 | 15 | @end 16 | 17 | -------------------------------------------------------------------------------- /TOPagingViewExample/TOAppDelegate.m: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.m 3 | // TOPagingViewExample 4 | // 5 | // Created by Tim Oliver on 2020/03/23. 6 | // Copyright © 2020 Tim Oliver. All rights reserved. 7 | // 8 | 9 | #import "TOAppDelegate.h" 10 | #import "TOViewController.h" 11 | 12 | @interface TOAppDelegate () 13 | 14 | @end 15 | 16 | @implementation TOAppDelegate 17 | 18 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { 19 | 20 | self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; 21 | self.window.rootViewController = [[TOViewController alloc] init]; 22 | [self.window makeKeyAndVisible]; 23 | 24 | #if TARGET_OS_MACCATALYST 25 | if (@available(iOS 13.0, *)) { 26 | self.window.windowScene.titlebar.titleVisibility = UITitlebarTitleVisibilityHidden; 27 | } 28 | #endif 29 | 30 | return YES; 31 | } 32 | 33 | @end 34 | -------------------------------------------------------------------------------- /TOPagingViewExample/TOTestPageView.h: -------------------------------------------------------------------------------- 1 | // 2 | // TOTestPageView.h 3 | // TOPagingViewExample 4 | // 5 | // Created by Tim Oliver on 2020/03/25. 6 | // Copyright © 2020 Tim Oliver. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | NS_ASSUME_NONNULL_BEGIN 12 | 13 | @interface TOTestPageView : UIView 14 | 15 | @property (nonatomic, assign) NSInteger number; 16 | 17 | @end 18 | 19 | NS_ASSUME_NONNULL_END 20 | -------------------------------------------------------------------------------- /TOPagingViewExample/TOTestPageView.m: -------------------------------------------------------------------------------- 1 | // 2 | // TOTestPageView.m 3 | // TOPagingViewExample 4 | // 5 | // Created by Tim Oliver on 2020/03/25. 6 | // Copyright © 2020 Tim Oliver. All rights reserved. 7 | // 8 | 9 | #import "TOTestPageView.h" 10 | #import "TOPagingView.h" 11 | 12 | @interface TOTestPageView () 13 | 14 | @property (nonatomic, strong) UILabel *numberLabel; 15 | 16 | @end 17 | 18 | @implementation TOTestPageView 19 | 20 | - (instancetype)initWithFrame:(CGRect)frame 21 | { 22 | if (self = [super initWithFrame:frame]) { 23 | self.backgroundColor = [UIColor redColor]; 24 | 25 | self.numberLabel = [[UILabel alloc] initWithFrame:(CGRect){0,0,320,128}]; 26 | self.numberLabel.textColor = [UIColor whiteColor]; 27 | self.numberLabel.font = [UIFont boldSystemFontOfSize:100.0f]; 28 | self.numberLabel.textAlignment = NSTextAlignmentCenter; 29 | [self addSubview:self.numberLabel]; 30 | } 31 | 32 | return self; 33 | } 34 | 35 | - (void)layoutSubviews 36 | { 37 | [super layoutSubviews]; 38 | 39 | self.numberLabel.center = (CGPoint){CGRectGetMidX(self.bounds), 40 | CGRectGetMidY(self.bounds)}; 41 | self.numberLabel.frame = CGRectIntegral(self.numberLabel.frame); 42 | 43 | // Private API. Don't actually use this. 44 | if (@available(iOS 13.0, *)) { 45 | CGFloat cornerRadius = [[[UIScreen mainScreen] valueForKey:@"_displayCornerRadius"] floatValue]; 46 | self.layer.cornerRadius = cornerRadius; 47 | self.layer.cornerCurve = kCACornerCurveContinuous; 48 | } 49 | } 50 | 51 | - (void)setNumber:(NSInteger)number 52 | { 53 | _number = number; 54 | self.numberLabel.text = [NSString stringWithFormat:@"%ld", (long)number]; 55 | [self setNeedsLayout]; 56 | } 57 | 58 | #pragma mark - TOPagingViewPage 59 | 60 | - (BOOL)isInitialPage { 61 | return [self.numberLabel.text isEqualToString:@"0"]; 62 | } 63 | 64 | - (void)setPageDirection:(TOPagingViewDirection)direction { 65 | const BOOL isReversed = (direction == TOPagingViewDirectionRightToLeft); 66 | NSLog(@"Page number %@ was set to: %@", self.numberLabel.text, (isReversed ? @"Left" : @"Right")); 67 | } 68 | 69 | @end 70 | -------------------------------------------------------------------------------- /TOPagingViewExample/TOViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.h 3 | // TOPagingViewExample 4 | // 5 | // Created by Tim Oliver on 2020/03/23. 6 | // Copyright © 2020 Tim Oliver. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface TOViewController : UIViewController 12 | 13 | @end 14 | 15 | -------------------------------------------------------------------------------- /TOPagingViewExample/TOViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.m 3 | // TOPagingViewExample 4 | // 5 | // Created by Tim Oliver on 2020/03/23. 6 | // Copyright © 2020 Tim Oliver. All rights reserved. 7 | // 8 | 9 | #import "TOViewController.h" 10 | #import "TOPagingView.h" 11 | #import "TOTestPageView.h" 12 | 13 | @interface TOViewController () 14 | 15 | // Current page state tracking 16 | @property (nonatomic, assign) NSInteger pageIndex; 17 | 18 | // UI 19 | @property (nonatomic, strong) TOPagingView *pagingView; 20 | @property (nonatomic, strong) UIButton *button; 21 | 22 | @end 23 | 24 | @implementation TOViewController 25 | 26 | #pragma mark - Paging View Data Source - 27 | 28 | - (TOTestPageView *)pagingView:(TOPagingView *)pagingView 29 | pageViewForType:(TOPagingViewPageType)type 30 | currentPageView:(TOTestPageView *)currentPageView { 31 | TOTestPageView *pageView = [pagingView dequeueReusablePageView]; 32 | 33 | switch (type) { 34 | case TOPagingViewPageTypeCurrent: 35 | pageView.number = self.pageIndex; 36 | break; 37 | case TOPagingViewPageTypeNext: 38 | pageView.number = self.pageIndex + 1; 39 | break; 40 | case TOPagingViewPageTypePrevious: 41 | pageView.number = self.pageIndex - 1; 42 | break; 43 | } 44 | 45 | return pageView; 46 | } 47 | 48 | #pragma mark - Paging View Delegate - 49 | 50 | -(void)pagingView:(TOPagingView *)pagingView willTurnToPageOfType:(TOPagingViewPageType)type{ 51 | // This delegate event is called quite liberally every time the user causes an action that 52 | // 'might' result in a page turn transaction occurring. This is useful as a catch to check the current 53 | // state of incoming data, and perform any new pre-loads that may have occurred in the meantime. 54 | 55 | NSLog(@"Paging view will to turn to: %@", [self stringForType:type]); 56 | } 57 | 58 | - (void)pagingView:(TOPagingView *)pagingView didTurnToPageOfType:(TOPagingViewPageType)type{ 59 | // This delegate event is called once it has been confirmed that the pages have crossed over the threshold 60 | // and a new page just officially became the "current" page. This is where any UI or state attached to this 61 | // view can be safely updated to match this view. This is called before the data source requests the next page 62 | // in order to update the state that will reflect what the data source needs to generate. 63 | 64 | if (type == TOPagingViewPageTypeNext) { _pageIndex++; } 65 | if (type == TOPagingViewPageTypePrevious) { _pageIndex--; } 66 | 67 | NSLog(@"Paging view did turn to: %@ at page %ld", [self stringForType:type], (long)self.pageIndex); 68 | } 69 | 70 | - (void)pagingView:(TOPagingView *)pagingView didChangeToPageDirection:(TOPagingViewDirection)direction { 71 | // This delegate is called when dynamic page direction detection is enabled and the scroll view 72 | // has determined the user has committed to a new page direction. It is only called once per interaction. 73 | BOOL isReversed = (direction == TOPagingViewDirectionRightToLeft); 74 | NSString *directionString = (isReversed ? @"Left" : @"Right"); 75 | [self.button setTitle:directionString forState:UIControlStateNormal]; 76 | 77 | NSLog(@"Paging view did change reading direction to: %@", directionString); 78 | } 79 | 80 | - (NSString *)stringForType:(TOPagingViewPageType)type { 81 | switch(type) { 82 | case TOPagingViewPageTypeCurrent: return @"Current"; 83 | case TOPagingViewPageTypeNext: return @"Next"; 84 | case TOPagingViewPageTypePrevious: return @"Previous"; 85 | } 86 | return nil; 87 | } 88 | 89 | #pragma mark - Gesture Recognizer - 90 | 91 | - (void)tapGestureRecognized:(UITapGestureRecognizer *)recgonizer { 92 | CGPoint tapPoint = [recgonizer locationInView:self.view]; 93 | CGFloat halfBoundWidth = CGRectGetWidth(self.view.bounds) / 2.0f; 94 | 95 | if (tapPoint.x < halfBoundWidth) { 96 | [self.pagingView turnToLeftPageAnimated:YES]; 97 | } 98 | else { 99 | [self.pagingView turnToRightPageAnimated:YES]; 100 | } 101 | } 102 | 103 | #pragma mark - View Controller Lifecycle - 104 | 105 | - (UIStatusBarStyle)preferredStatusBarStyle { 106 | return UIStatusBarStyleLightContent; 107 | } 108 | 109 | - (BOOL)prefersHomeIndicatorAutoHidden { return YES; } 110 | 111 | - (void)viewDidLoad { 112 | [super viewDidLoad]; 113 | 114 | // State tracking 115 | self.pageIndex = 0; 116 | 117 | // View Controller Config 118 | self.view.backgroundColor = [UIColor blackColor]; 119 | 120 | // Paging view set-up and configuration 121 | self.pagingView = [[TOPagingView alloc] initWithFrame:self.view.bounds]; 122 | //self.pagingView.isDynamicPageDirectionEnabled = YES; 123 | self.pagingView.dataSource = self; 124 | self.pagingView.delegate = self; 125 | [self.pagingView registerPageViewClass:TOTestPageView.class]; 126 | self.pagingView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; 127 | [self.view addSubview:self.pagingView]; 128 | 129 | // Force it to become first responder to receive keyboard input 130 | [self.pagingView becomeFirstResponder]; 131 | 132 | // Add a tap recognizer to turn pages 133 | UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapGestureRecognized:)]; 134 | [self.pagingView addGestureRecognizer:tapRecognizer]; 135 | 136 | // Add a button to toggle page turning direction 137 | UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem]; 138 | button.tintColor = [UIColor whiteColor]; 139 | [button setTitle:@"Right" forState:UIControlStateNormal]; 140 | [button addTarget:self action:@selector(buttonTapped) forControlEvents:UIControlEventTouchUpInside]; 141 | button.titleLabel.font = [UIFont systemFontOfSize:22]; 142 | button.frame = (CGRect){0,0,100,50}; 143 | button.center = (CGPoint){CGRectGetMidX(self.pagingView.frame), CGRectGetHeight(self.pagingView.frame) - 50}; 144 | button.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin | UIViewAutoresizingFlexibleTopMargin; 145 | [self.view addSubview:button]; 146 | self.button = button; 147 | } 148 | 149 | - (void)buttonTapped { 150 | TOPagingViewDirection direction = self.pagingView.pageScrollDirection; 151 | if (direction == TOPagingViewDirectionLeftToRight) { 152 | direction = TOPagingViewDirectionRightToLeft; 153 | [self.button setTitle:@"Left" forState:UIControlStateNormal]; 154 | } 155 | else { 156 | direction = TOPagingViewDirectionLeftToRight; 157 | [self.button setTitle:@"Right" forState:UIControlStateNormal]; 158 | } 159 | self.pagingView.pageScrollDirection = direction; 160 | } 161 | 162 | @end 163 | -------------------------------------------------------------------------------- /TOPagingViewExample/main.m: -------------------------------------------------------------------------------- 1 | // 2 | // main.m 3 | // TOPagingViewExample 4 | // 5 | // Created by Tim Oliver on 2020/03/23. 6 | // Copyright © 2020 Tim Oliver. All rights reserved. 7 | // 8 | 9 | #import 10 | #import "TOAppDelegate.h" 11 | 12 | int main(int argc, char * argv[]) { 13 | NSString * appDelegateClassName; 14 | @autoreleasepool { 15 | // Setup code that might create autoreleased objects goes here. 16 | appDelegateClassName = NSStringFromClass([TOAppDelegate class]); 17 | } 18 | return UIApplicationMain(argc, argv, nil, appDelegateClassName); 19 | } 20 | -------------------------------------------------------------------------------- /TOPagingViewTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /TOPagingViewTests/TODynamicPageViewTests.m: -------------------------------------------------------------------------------- 1 | // 2 | // TOPagingViewTests.m 3 | // TOPagingViewTests 4 | // 5 | // Created by Tim Oliver on 2020/03/23. 6 | // Copyright © 2020 Tim Oliver. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface TOPagingViewTests : XCTestCase 12 | 13 | @end 14 | 15 | @implementation TOPagingViewTests 16 | 17 | - (void)setUp { 18 | // Put setup code here. This method is called before the invocation of each test method in the class. 19 | } 20 | 21 | - (void)tearDown { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | - (void)testExample { 26 | // This is an example of a functional test case. 27 | // Use XCTAssert and related functions to verify your tests produce the correct results. 28 | } 29 | 30 | - (void)testPerformanceExample { 31 | // This is an example of a performance test case. 32 | [self measureBlock:^{ 33 | // Put the code you want to measure the time of here. 34 | }]; 35 | } 36 | 37 | @end 38 | --------------------------------------------------------------------------------