├── IXRefreshableScrollView
├── IXRefreshableScrollView
│ ├── IXRefreshableScrollView.entitlements
│ ├── AppDelegate.swift
│ ├── Info.plist
│ ├── Assets.xcassets
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── ViewController.swift
│ ├── IXRefreshableScrollView.swift
│ └── Base.lproj
│ │ └── Main.storyboard
└── IXRefreshableScrollView.xcodeproj
│ ├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── project.pbxproj
├── LICENSE
├── .gitignore
└── README.md
/IXRefreshableScrollView/IXRefreshableScrollView/IXRefreshableScrollView.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/IXRefreshableScrollView/IXRefreshableScrollView.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/IXRefreshableScrollView/IXRefreshableScrollView/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | @NSApplicationMain
4 | class AppDelegate: NSObject, NSApplicationDelegate {
5 |
6 | func applicationDidFinishLaunching(_ aNotification: Notification) {
7 | // Insert code here to initialize your application
8 | }
9 |
10 | }
11 |
12 |
--------------------------------------------------------------------------------
/IXRefreshableScrollView/IXRefreshableScrollView.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 ix4n33
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 |
--------------------------------------------------------------------------------
/IXRefreshableScrollView/IXRefreshableScrollView/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIconFile
10 |
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | 1.0
21 | CFBundleVersion
22 | 1
23 | LSMinimumSystemVersion
24 | $(MACOSX_DEPLOYMENT_TARGET)
25 | NSHumanReadableCopyright
26 | Copyright © 2017 IXAN. All rights reserved.
27 | NSMainStoryboardFile
28 | Main
29 | NSPrincipalClass
30 | NSApplication
31 |
32 |
33 |
--------------------------------------------------------------------------------
/IXRefreshableScrollView/IXRefreshableScrollView/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "mac",
5 | "size" : "16x16",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "mac",
10 | "size" : "16x16",
11 | "scale" : "2x"
12 | },
13 | {
14 | "idiom" : "mac",
15 | "size" : "32x32",
16 | "scale" : "1x"
17 | },
18 | {
19 | "idiom" : "mac",
20 | "size" : "32x32",
21 | "scale" : "2x"
22 | },
23 | {
24 | "idiom" : "mac",
25 | "size" : "128x128",
26 | "scale" : "1x"
27 | },
28 | {
29 | "idiom" : "mac",
30 | "size" : "128x128",
31 | "scale" : "2x"
32 | },
33 | {
34 | "idiom" : "mac",
35 | "size" : "256x256",
36 | "scale" : "1x"
37 | },
38 | {
39 | "idiom" : "mac",
40 | "size" : "256x256",
41 | "scale" : "2x"
42 | },
43 | {
44 | "idiom" : "mac",
45 | "size" : "512x512",
46 | "scale" : "1x"
47 | },
48 | {
49 | "idiom" : "mac",
50 | "size" : "512x512",
51 | "scale" : "2x"
52 | }
53 | ],
54 | "info" : {
55 | "version" : 1,
56 | "author" : "xcode"
57 | }
58 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | .DS_Store
6 |
7 | ## Build generated
8 | build/
9 | DerivedData/
10 |
11 | ## Various settings
12 | *.pbxuser
13 | !default.pbxuser
14 | *.mode1v3
15 | !default.mode1v3
16 | *.mode2v3
17 | !default.mode2v3
18 | *.perspectivev3
19 | !default.perspectivev3
20 | xcuserdata/
21 |
22 | ## Other
23 | *.moved-aside
24 | *.xccheckout
25 | *.xcscmblueprint
26 |
27 | ## Obj-C/Swift specific
28 | *.hmap
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | .build/
43 |
44 | # CocoaPods
45 | #
46 | # We recommend against adding the Pods directory to your .gitignore. However
47 | # you should judge for yourself, the pros and cons are mentioned at:
48 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
49 | #
50 | # Pods/
51 |
52 | # Carthage
53 | #
54 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
55 | # Carthage/Checkouts
56 |
57 | Carthage/Build
58 |
59 | # fastlane
60 | #
61 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
62 | # screenshots whenever they are needed.
63 | # For more information about the recommended setup visit:
64 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
65 |
66 | fastlane/report.xml
67 | fastlane/Preview.html
68 | fastlane/screenshots
69 | fastlane/test_output
70 |
--------------------------------------------------------------------------------
/IXRefreshableScrollView/IXRefreshableScrollView/ViewController.swift:
--------------------------------------------------------------------------------
1 | import Cocoa
2 |
3 | class ViewController: NSViewController {
4 |
5 | @IBOutlet weak var scrollView: IXScrollView!
6 | @IBOutlet weak var tableView: NSTableView!
7 |
8 | var tableDatas = [String]()
9 |
10 | override func viewDidLoad() {
11 | super.viewDidLoad()
12 |
13 | scrollView.canPullToLoad = true
14 |
15 | // setting target & actions
16 |
17 | scrollView.target = self
18 | scrollView.refreshAction = #selector(refresh)
19 | scrollView.loadAction = #selector(load)
20 |
21 | // setting table view datas for test
22 |
23 | for _ in 0..<50 {
24 | tableDatas.append("testing testing 123")
25 | }
26 |
27 | tableView.reloadData()
28 | }
29 |
30 | // IBActions
31 |
32 | @IBAction func handleRefresh(_ sender: NSButton) {
33 | scrollView.beginRefreshing(scrollToTop: true)
34 | }
35 |
36 | @IBAction func handleLoad(_ sender: NSButton) {
37 | scrollView.beginLoading()
38 | }
39 |
40 |
41 | @IBAction func handleStop(_ sender: NSButton) {
42 | if scrollView.isRefreshing { scrollView.stopRefreshing() }
43 | if scrollView.isLoading { scrollView.stopLoading() }
44 | }
45 |
46 | @objc func refresh() {
47 | for _ in 0..<5 {
48 | tableDatas.insert("new added string", at: 0)
49 | }
50 |
51 | DispatchQueue.global().async {
52 | sleep(2)
53 | DispatchQueue.main.async {
54 | self.tableView.reloadData()
55 | self.scrollView.stopRefreshing()
56 | }
57 | }
58 | }
59 |
60 | @objc func load() {
61 | for _ in 0..<5 {
62 | tableDatas.append("new Added string")
63 | }
64 |
65 | DispatchQueue.global().async {
66 | sleep(2)
67 | DispatchQueue.main.async {
68 | self.tableView.reloadData()
69 | self.scrollView.stopLoading()
70 | }
71 | }
72 | }
73 |
74 | }
75 |
76 | extension ViewController: NSTableViewDelegate, NSTableViewDataSource {
77 | func numberOfRows(in tableView: NSTableView) -> Int {
78 | return tableDatas.count
79 | }
80 |
81 | func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
82 | let cell = tableView.makeView(withIdentifier: .tableCellId, owner: self) as! NSTableCellView
83 |
84 | cell.textField?.stringValue = tableDatas[row]
85 |
86 | return cell
87 | }
88 | }
89 |
90 | extension NSUserInterfaceItemIdentifier {
91 | static var tableCellId = NSUserInterfaceItemIdentifier("cellId")
92 | }
93 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # IXRefreshableScrollView
2 |
3 | NSScrollView subclass with pull to refresh from top and pull to load from bottom.
4 |
5 | NOTE: This project is still under development, which means there are still a lot of bugs to be fixed.
6 |
7 | ## Installation
8 |
9 | Drag `IXRefrashableScrollView.swift` to your project folder.
10 |
11 | ## Interface
12 |
13 | ```swift
14 | protocol IXScrollViewRefreshable : class {
15 |
16 | /// The view to display when pulling from edge.
17 | func ixScrollView(_ scrollView: IXScrollView, viewForSupplementaryElementOfKind kind: IXScrollView.SupplementaryElementKind) -> IXScrollView.SupplementaryView
18 |
19 | /// The Height of supplementary view.
20 | func ixScrollView(_ scrollView: IXScrollView, heightOfSupplementaryElementOfKind kind: IXScrollView.SupplementaryElementKind) -> CGFloat
21 |
22 | /// Decided when should scroll view trigger the pulling action.
23 | func ixScrollView(_ scrollView: IXScrollView, triggerBehaviorOfSupplementaryElementOfKind kind: IXScrollView.SupplementaryElementKind) -> IXScrollView.SupplementaryTriggerBehavior
24 |
25 | /// Callback to update supplementary view when pulling from edge.
26 | func ixScrollView(_ scrollView: IXScrollView, updateSupplementaryElement supplementaryView: IXScrollView.SupplementaryView, ofKind kind: IXScrollView.SupplementaryElementKind, withProgress progress: CGFloat)
27 |
28 | /// Callback when pulling action has been triggered.
29 | /// Begin your custom supplementary view animation here.
30 | func ixScrollView(_ scrollView: IXScrollView, didTriggerSupplementaryElement supplementaryView: IXScrollView.SupplementaryView, ofKind kind: IXScrollView.SupplementaryElementKind)
31 |
32 | /// Callback when pulling action is done or cancel.
33 | /// Stop your custom supplementary view animation here.
34 | func ixScrollView(_ scrollView: IXScrollView, didStopSupplementaryElement supplementaryView: IXScrollView.SupplementaryView, ofKind kind: IXScrollView.SupplementaryElementKind)
35 |
36 | }
37 |
38 | extension IXScrollViewRefreshable where Self : RefreshableScrollView.IXScrollView {
39 | func beginRefreshing(scrollToTop: Bool = false)
40 | func stopRefreshing(scrollToTop: Bool = false)
41 | func beginLoading()
42 | func stopLoading(scrollToBottom: Bool = false)
43 | }
44 |
45 | class IXScrollView : NSScrollView, IXScrollViewRefreshable {
46 |
47 | enum SupplementaryElementKind {
48 |
49 | /// A kind of view to display when pulling from top.
50 | case refresh
51 |
52 | /// A kind of view to display when pulling from bottom.
53 | case load
54 | }
55 |
56 | enum SupplementaryTriggerBehavior {
57 |
58 | /// Scroll view is triggered as soon as it hit the trigger rect.
59 | case instant
60 |
61 | /// Scroll view is triggered if it's inside trigger rect when finger release.
62 | case overThreshold
63 | }
64 |
65 | /// The class scroll view to display when pulling from egde. This is used by default to show the progress indicator.
66 | /// Subclass this if you want to provide a custom one. Or you can provide a NSView subclass if you want.
67 | class SupplementaryView : NSView { ... }
68 |
69 | /// A bool value control whether scroll view is refreshable. `true` by default.
70 | var canPullToRefresh: Bool
71 |
72 | /// A bool value control whether scroll view is loadable. `false` by default.
73 | var canPullToLoad: Bool
74 |
75 | /// A bool value indicate whether scroll view is refreshing.
76 | var isRefreshing: Bool { get }
77 |
78 | /// A bool value indicate whether scroll view is loading.
79 | var isLoading: Bool { get }
80 |
81 | /// Perform Haptic Feedback when reach the trigger threshold. `true` by default.
82 | var triggeredWithHapticFeedback: Bool
83 |
84 | /// Pulling action to execute when triggered.
85 | var refreshAction: Selector?
86 |
87 | /// Pulling action to execute when triggered.
88 | var loadAction: Selector?
89 |
90 | /// The target to call action
91 | var target: AnyObject?
92 | }
93 | ```
94 |
95 | ## Known Issues
96 |
97 | - `SupplementaryTriggerBehavior.instant` doesn't work properly yet.
98 | - Programmatically call `beginRefreshing()` or `beginLoading` won't scroll to edge.
99 | - When `stopRefreshing` after a document height changed, scrolling twitch.
100 |
101 | ## Update log
102 |
103 | v0.0.3
104 |
105 | - remove `triggeredHeight`, use view height instead.
106 | - calling `beginRefreshing` programmatically now have a option to scroll to top.
107 | - code cleaning.
108 |
109 | v0.0.2
110 |
111 | - code cleaning.
112 |
113 | v0.0.1
114 |
115 | - Hello world!
116 |
--------------------------------------------------------------------------------
/IXRefreshableScrollView/IXRefreshableScrollView.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 48;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | FF436BBB1FB5B3320025CDB6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF436BBA1FB5B3320025CDB6 /* AppDelegate.swift */; };
11 | FF436BBD1FB5B3320025CDB6 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF436BBC1FB5B3320025CDB6 /* ViewController.swift */; };
12 | FF436BBF1FB5B3320025CDB6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF436BBE1FB5B3320025CDB6 /* Assets.xcassets */; };
13 | FF436BC21FB5B3320025CDB6 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FF436BC01FB5B3320025CDB6 /* Main.storyboard */; };
14 | FF436BCC1FB5BCA30025CDB6 /* IXRefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF436BCB1FB5BB100025CDB6 /* IXRefreshableScrollView.swift */; };
15 | /* End PBXBuildFile section */
16 |
17 | /* Begin PBXFileReference section */
18 | FF436BB71FB5B3320025CDB6 /* IXRefreshableScrollView.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IXRefreshableScrollView.app; sourceTree = BUILT_PRODUCTS_DIR; };
19 | FF436BBA1FB5B3320025CDB6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
20 | FF436BBC1FB5B3320025CDB6 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; };
21 | FF436BBE1FB5B3320025CDB6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
22 | FF436BC11FB5B3320025CDB6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
23 | FF436BC31FB5B3320025CDB6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
24 | FF436BC41FB5B3320025CDB6 /* IXRefreshableScrollView.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IXRefreshableScrollView.entitlements; sourceTree = ""; };
25 | FF436BCB1FB5BB100025CDB6 /* IXRefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IXRefreshableScrollView.swift; sourceTree = ""; };
26 | /* End PBXFileReference section */
27 |
28 | /* Begin PBXFrameworksBuildPhase section */
29 | FF436BB41FB5B3320025CDB6 /* Frameworks */ = {
30 | isa = PBXFrameworksBuildPhase;
31 | buildActionMask = 2147483647;
32 | files = (
33 | );
34 | runOnlyForDeploymentPostprocessing = 0;
35 | };
36 | /* End PBXFrameworksBuildPhase section */
37 |
38 | /* Begin PBXGroup section */
39 | FF436BAE1FB5B3320025CDB6 = {
40 | isa = PBXGroup;
41 | children = (
42 | FF436BB91FB5B3320025CDB6 /* IXRefreshableScrollView */,
43 | FF436BB81FB5B3320025CDB6 /* Products */,
44 | );
45 | sourceTree = "";
46 | };
47 | FF436BB81FB5B3320025CDB6 /* Products */ = {
48 | isa = PBXGroup;
49 | children = (
50 | FF436BB71FB5B3320025CDB6 /* IXRefreshableScrollView.app */,
51 | );
52 | name = Products;
53 | sourceTree = "";
54 | };
55 | FF436BB91FB5B3320025CDB6 /* IXRefreshableScrollView */ = {
56 | isa = PBXGroup;
57 | children = (
58 | FF436BCB1FB5BB100025CDB6 /* IXRefreshableScrollView.swift */,
59 | FF436BBA1FB5B3320025CDB6 /* AppDelegate.swift */,
60 | FF436BBC1FB5B3320025CDB6 /* ViewController.swift */,
61 | FF436BBE1FB5B3320025CDB6 /* Assets.xcassets */,
62 | FF436BC01FB5B3320025CDB6 /* Main.storyboard */,
63 | FF436BC31FB5B3320025CDB6 /* Info.plist */,
64 | FF436BC41FB5B3320025CDB6 /* IXRefreshableScrollView.entitlements */,
65 | );
66 | path = IXRefreshableScrollView;
67 | sourceTree = "";
68 | };
69 | /* End PBXGroup section */
70 |
71 | /* Begin PBXNativeTarget section */
72 | FF436BB61FB5B3320025CDB6 /* IXRefreshableScrollView */ = {
73 | isa = PBXNativeTarget;
74 | buildConfigurationList = FF436BC71FB5B3320025CDB6 /* Build configuration list for PBXNativeTarget "IXRefreshableScrollView" */;
75 | buildPhases = (
76 | FF436BB31FB5B3320025CDB6 /* Sources */,
77 | FF436BB41FB5B3320025CDB6 /* Frameworks */,
78 | FF436BB51FB5B3320025CDB6 /* Resources */,
79 | );
80 | buildRules = (
81 | );
82 | dependencies = (
83 | );
84 | name = IXRefreshableScrollView;
85 | productName = IXRefreshableScrollView;
86 | productReference = FF436BB71FB5B3320025CDB6 /* IXRefreshableScrollView.app */;
87 | productType = "com.apple.product-type.application";
88 | };
89 | /* End PBXNativeTarget section */
90 |
91 | /* Begin PBXProject section */
92 | FF436BAF1FB5B3320025CDB6 /* Project object */ = {
93 | isa = PBXProject;
94 | attributes = {
95 | LastSwiftUpdateCheck = 0900;
96 | LastUpgradeCheck = 0900;
97 | ORGANIZATIONNAME = IXAN;
98 | TargetAttributes = {
99 | FF436BB61FB5B3320025CDB6 = {
100 | CreatedOnToolsVersion = 9.0;
101 | ProvisioningStyle = Automatic;
102 | SystemCapabilities = {
103 | com.apple.Sandbox = {
104 | enabled = 0;
105 | };
106 | };
107 | };
108 | };
109 | };
110 | buildConfigurationList = FF436BB21FB5B3320025CDB6 /* Build configuration list for PBXProject "IXRefreshableScrollView" */;
111 | compatibilityVersion = "Xcode 8.0";
112 | developmentRegion = en;
113 | hasScannedForEncodings = 0;
114 | knownRegions = (
115 | en,
116 | Base,
117 | );
118 | mainGroup = FF436BAE1FB5B3320025CDB6;
119 | productRefGroup = FF436BB81FB5B3320025CDB6 /* Products */;
120 | projectDirPath = "";
121 | projectRoot = "";
122 | targets = (
123 | FF436BB61FB5B3320025CDB6 /* IXRefreshableScrollView */,
124 | );
125 | };
126 | /* End PBXProject section */
127 |
128 | /* Begin PBXResourcesBuildPhase section */
129 | FF436BB51FB5B3320025CDB6 /* Resources */ = {
130 | isa = PBXResourcesBuildPhase;
131 | buildActionMask = 2147483647;
132 | files = (
133 | FF436BBF1FB5B3320025CDB6 /* Assets.xcassets in Resources */,
134 | FF436BC21FB5B3320025CDB6 /* Main.storyboard in Resources */,
135 | );
136 | runOnlyForDeploymentPostprocessing = 0;
137 | };
138 | /* End PBXResourcesBuildPhase section */
139 |
140 | /* Begin PBXSourcesBuildPhase section */
141 | FF436BB31FB5B3320025CDB6 /* Sources */ = {
142 | isa = PBXSourcesBuildPhase;
143 | buildActionMask = 2147483647;
144 | files = (
145 | FF436BBD1FB5B3320025CDB6 /* ViewController.swift in Sources */,
146 | FF436BCC1FB5BCA30025CDB6 /* IXRefreshableScrollView.swift in Sources */,
147 | FF436BBB1FB5B3320025CDB6 /* AppDelegate.swift in Sources */,
148 | );
149 | runOnlyForDeploymentPostprocessing = 0;
150 | };
151 | /* End PBXSourcesBuildPhase section */
152 |
153 | /* Begin PBXVariantGroup section */
154 | FF436BC01FB5B3320025CDB6 /* Main.storyboard */ = {
155 | isa = PBXVariantGroup;
156 | children = (
157 | FF436BC11FB5B3320025CDB6 /* Base */,
158 | );
159 | name = Main.storyboard;
160 | sourceTree = "";
161 | };
162 | /* End PBXVariantGroup section */
163 |
164 | /* Begin XCBuildConfiguration section */
165 | FF436BC51FB5B3320025CDB6 /* Debug */ = {
166 | isa = XCBuildConfiguration;
167 | buildSettings = {
168 | ALWAYS_SEARCH_USER_PATHS = NO;
169 | CLANG_ANALYZER_NONNULL = YES;
170 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
171 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
172 | CLANG_CXX_LIBRARY = "libc++";
173 | CLANG_ENABLE_MODULES = YES;
174 | CLANG_ENABLE_OBJC_ARC = YES;
175 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
176 | CLANG_WARN_BOOL_CONVERSION = YES;
177 | CLANG_WARN_COMMA = YES;
178 | CLANG_WARN_CONSTANT_CONVERSION = YES;
179 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
180 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
181 | CLANG_WARN_EMPTY_BODY = YES;
182 | CLANG_WARN_ENUM_CONVERSION = YES;
183 | CLANG_WARN_INFINITE_RECURSION = YES;
184 | CLANG_WARN_INT_CONVERSION = YES;
185 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
186 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
187 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
188 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
189 | CLANG_WARN_STRICT_PROTOTYPES = YES;
190 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
191 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
192 | CLANG_WARN_UNREACHABLE_CODE = YES;
193 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
194 | CODE_SIGN_IDENTITY = "-";
195 | COPY_PHASE_STRIP = NO;
196 | DEBUG_INFORMATION_FORMAT = dwarf;
197 | ENABLE_STRICT_OBJC_MSGSEND = YES;
198 | ENABLE_TESTABILITY = YES;
199 | GCC_C_LANGUAGE_STANDARD = gnu11;
200 | GCC_DYNAMIC_NO_PIC = NO;
201 | GCC_NO_COMMON_BLOCKS = YES;
202 | GCC_OPTIMIZATION_LEVEL = 0;
203 | GCC_PREPROCESSOR_DEFINITIONS = (
204 | "DEBUG=1",
205 | "$(inherited)",
206 | );
207 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
208 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
209 | GCC_WARN_UNDECLARED_SELECTOR = YES;
210 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
211 | GCC_WARN_UNUSED_FUNCTION = YES;
212 | GCC_WARN_UNUSED_VARIABLE = YES;
213 | MACOSX_DEPLOYMENT_TARGET = 10.13;
214 | MTL_ENABLE_DEBUG_INFO = YES;
215 | ONLY_ACTIVE_ARCH = YES;
216 | SDKROOT = macosx;
217 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
218 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
219 | };
220 | name = Debug;
221 | };
222 | FF436BC61FB5B3320025CDB6 /* Release */ = {
223 | isa = XCBuildConfiguration;
224 | buildSettings = {
225 | ALWAYS_SEARCH_USER_PATHS = NO;
226 | CLANG_ANALYZER_NONNULL = YES;
227 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
228 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
229 | CLANG_CXX_LIBRARY = "libc++";
230 | CLANG_ENABLE_MODULES = YES;
231 | CLANG_ENABLE_OBJC_ARC = YES;
232 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
233 | CLANG_WARN_BOOL_CONVERSION = YES;
234 | CLANG_WARN_COMMA = YES;
235 | CLANG_WARN_CONSTANT_CONVERSION = YES;
236 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
237 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
238 | CLANG_WARN_EMPTY_BODY = YES;
239 | CLANG_WARN_ENUM_CONVERSION = YES;
240 | CLANG_WARN_INFINITE_RECURSION = YES;
241 | CLANG_WARN_INT_CONVERSION = YES;
242 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
243 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
244 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
245 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
246 | CLANG_WARN_STRICT_PROTOTYPES = YES;
247 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
248 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
249 | CLANG_WARN_UNREACHABLE_CODE = YES;
250 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
251 | CODE_SIGN_IDENTITY = "-";
252 | COPY_PHASE_STRIP = NO;
253 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
254 | ENABLE_NS_ASSERTIONS = NO;
255 | ENABLE_STRICT_OBJC_MSGSEND = YES;
256 | GCC_C_LANGUAGE_STANDARD = gnu11;
257 | GCC_NO_COMMON_BLOCKS = YES;
258 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
259 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
260 | GCC_WARN_UNDECLARED_SELECTOR = YES;
261 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
262 | GCC_WARN_UNUSED_FUNCTION = YES;
263 | GCC_WARN_UNUSED_VARIABLE = YES;
264 | MACOSX_DEPLOYMENT_TARGET = 10.13;
265 | MTL_ENABLE_DEBUG_INFO = NO;
266 | SDKROOT = macosx;
267 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
268 | };
269 | name = Release;
270 | };
271 | FF436BC81FB5B3320025CDB6 /* Debug */ = {
272 | isa = XCBuildConfiguration;
273 | buildSettings = {
274 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
275 | CODE_SIGN_STYLE = Automatic;
276 | COMBINE_HIDPI_IMAGES = YES;
277 | INFOPLIST_FILE = "$(SRCROOT)/IXRefreshableScrollView/Info.plist";
278 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
279 | PRODUCT_BUNDLE_IDENTIFIER = site.ixan.IXRefreshableScrollView;
280 | PRODUCT_NAME = "$(TARGET_NAME)";
281 | SWIFT_VERSION = 4.0;
282 | };
283 | name = Debug;
284 | };
285 | FF436BC91FB5B3320025CDB6 /* Release */ = {
286 | isa = XCBuildConfiguration;
287 | buildSettings = {
288 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
289 | CODE_SIGN_STYLE = Automatic;
290 | COMBINE_HIDPI_IMAGES = YES;
291 | INFOPLIST_FILE = "$(SRCROOT)/IXRefreshableScrollView/Info.plist";
292 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
293 | PRODUCT_BUNDLE_IDENTIFIER = site.ixan.IXRefreshableScrollView;
294 | PRODUCT_NAME = "$(TARGET_NAME)";
295 | SWIFT_VERSION = 4.0;
296 | };
297 | name = Release;
298 | };
299 | /* End XCBuildConfiguration section */
300 |
301 | /* Begin XCConfigurationList section */
302 | FF436BB21FB5B3320025CDB6 /* Build configuration list for PBXProject "IXRefreshableScrollView" */ = {
303 | isa = XCConfigurationList;
304 | buildConfigurations = (
305 | FF436BC51FB5B3320025CDB6 /* Debug */,
306 | FF436BC61FB5B3320025CDB6 /* Release */,
307 | );
308 | defaultConfigurationIsVisible = 0;
309 | defaultConfigurationName = Release;
310 | };
311 | FF436BC71FB5B3320025CDB6 /* Build configuration list for PBXNativeTarget "IXRefreshableScrollView" */ = {
312 | isa = XCConfigurationList;
313 | buildConfigurations = (
314 | FF436BC81FB5B3320025CDB6 /* Debug */,
315 | FF436BC91FB5B3320025CDB6 /* Release */,
316 | );
317 | defaultConfigurationIsVisible = 0;
318 | defaultConfigurationName = Release;
319 | };
320 | /* End XCConfigurationList section */
321 | };
322 | rootObject = FF436BAF1FB5B3320025CDB6 /* Project object */;
323 | }
324 |
--------------------------------------------------------------------------------
/IXRefreshableScrollView/IXRefreshableScrollView/IXRefreshableScrollView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Version 0.0.5
3 | //
4 | // MIT License
5 | //
6 | // Copyright (c) 2017 ix4n33
7 | //
8 | // Permission is hereby granted, free of charge, to any person obtaining a copy
9 | // of this software and associated documentation files (the "Software"), to deal
10 | // in the Software without restriction, including without limitation the rights
11 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12 | // copies of the Software, and to permit persons to whom the Software is
13 | // furnished to do so, subject to the following conditions:
14 | //
15 | // The above copyright notice and this permission notice shall be included in all
16 | // copies or substantial portions of the Software.
17 | //
18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24 | // SOFTWARE.
25 | //
26 |
27 | import Cocoa
28 |
29 | // MARK: - Protocols
30 |
31 | protocol IXScrollViewRefreshable: class {
32 |
33 | /// The view to display when pulling from edge.
34 | ///
35 | /// - Parameters:
36 | /// - scrollView: The scroll view that require the supplementary view.
37 | /// - kind: A enum indicate the kind of supplementary view.
38 | /// - Returns: The supplementary view should be display on the edge.
39 | func ixScrollView(_ scrollView: IXScrollView, viewForSupplementaryElementOfKind kind: IXScrollView.SupplementaryElementKind) -> IXScrollView.SupplementaryView
40 |
41 | /// The Height of supplementary view.
42 | /// Override this if you have a custom supplementary view that need a different height. This return 40 by default.
43 | ///
44 | /// - Parameters:
45 | /// - scrollView: The scroll view that require the supplementary view height.
46 | /// - kind: A enum indicate the kind of supplementary view.
47 | /// - Returns: The height of supplementary view.
48 | func ixScrollView(_ scrollView: IXScrollView, heightOfSupplementaryElementOfKind kind: IXScrollView.SupplementaryElementKind) -> CGFloat
49 |
50 | /// Decided when should scroll view trigger the pulling action.
51 | ///
52 | /// - Parameters:
53 | /// - scrollView: The scroll view that require the supplementary view trigger behavior.
54 | /// - kind: A enum indicate the kind of supplementary view.
55 | /// - Returns: The kind of behavior to trigger pulling action.
56 | func ixScrollView(_ scrollView: IXScrollView, triggerBehaviorOfSupplementaryElementOfKind kind: IXScrollView.SupplementaryElementKind) -> IXScrollView.SupplementaryTriggerBehavior
57 |
58 | /// Callback to update supplementary view when pulling from edge.
59 | ///
60 | /// - Parameters:
61 | /// - scrollView: The scroll view target.
62 | /// - supplementaryView: The supplementary view that need to update.
63 | /// - kind: A enum indicate the kind of supplementary view.
64 | /// - progress: A Float value from 0 to 1 indicate how far is pull from origin position to trigger threshold height. this can go beyond 1 if pull over threshold. Just in case if you want to add more animation when pulling over.
65 | func ixScrollView(_ scrollView: IXScrollView, updateSupplementaryElement supplementaryView: IXScrollView.SupplementaryView, ofKind kind: IXScrollView.SupplementaryElementKind, withProgress progress: CGFloat)
66 |
67 | /// Callback when pulling action has been triggered.
68 | /// Begin your custom supplementary view animation here.
69 | ///
70 | /// - Parameters:
71 | /// - scrollView: The scroll view target.
72 | /// - supplementaryView: The supplementary view that triggered.
73 | /// - kind: A enum indicate the kind of supplementary view.
74 | func ixScrollView(_ scrollView: IXScrollView, didTriggerSupplementaryElement supplementaryView: IXScrollView.SupplementaryView, ofKind kind: IXScrollView.SupplementaryElementKind)
75 |
76 |
77 | /// Callback when pulling action is done or cancel.
78 | /// Stop your custom supplementary view animation here.
79 | ///
80 | /// - Parameters:
81 | /// - scrollView: The scroll view target.
82 | /// - supplementaryView: The supplementary view that triggered.
83 | /// - kind: A enum indicate the kind of supplementary view.
84 | func ixScrollView(_ scrollView: IXScrollView, didStopSupplementaryElement supplementaryView: IXScrollView.SupplementaryView, ofKind kind: IXScrollView.SupplementaryElementKind)
85 | }
86 |
87 | // MARK: - Default protocol confirmation
88 |
89 | extension IXScrollViewRefreshable {
90 |
91 | func ixScrollView(_ scrollView: IXScrollView, viewForSupplementaryElementOfKind kind: IXScrollView.SupplementaryElementKind) -> IXScrollView.SupplementaryView {
92 | if kind == .refresh {
93 | if scrollView.supplementalRefreshView == nil {
94 | let view = IXScrollView.SupplementaryView(withIndicator: true)
95 | view.translatesAutoresizingMaskIntoConstraints = false
96 | return view
97 | }
98 | return scrollView.supplementalRefreshView!
99 | } else {
100 | if scrollView.supplementalLoadingView == nil {
101 | let view = IXScrollView.SupplementaryView(withIndicator: true)
102 | view.translatesAutoresizingMaskIntoConstraints = false
103 | return view
104 | }
105 | return scrollView.supplementalLoadingView!
106 | }
107 | }
108 |
109 | func ixScrollView(_ scrollView: IXScrollView, heightOfSupplementaryElementOfKind kind: IXScrollView.SupplementaryElementKind) -> CGFloat {
110 | return 40
111 | }
112 |
113 | func ixScrollView(_ scrollView: IXScrollView, updateSupplementaryElement supplementaryView: IXScrollView.SupplementaryView, ofKind kind: IXScrollView.SupplementaryElementKind, withProgress progress: CGFloat) {
114 | if !scrollView._isRefreshing || !scrollView._isLoading {
115 | supplementaryView.indicator.doubleValue = Double(progress * 100)
116 | supplementaryView.indicator.alphaValue = progress
117 | }
118 | }
119 |
120 | func ixScrollView(_ scrollView: IXScrollView, triggerBehaviorOfSupplementaryElementOfKind kind: IXScrollView.SupplementaryElementKind) -> IXScrollView.SupplementaryTriggerBehavior {
121 | return IXScrollView.SupplementaryTriggerBehavior.overThreshold
122 | }
123 |
124 | func ixScrollView(_ scrollView: IXScrollView, didTriggerSupplementaryElement supplementaryView: IXScrollView.SupplementaryView, ofKind kind: IXScrollView.SupplementaryElementKind) {
125 | supplementaryView.indicator.isIndeterminate = true
126 | supplementaryView.indicator.startAnimation(self)
127 | }
128 |
129 | func ixScrollView(_ scrollView: IXScrollView, didStopSupplementaryElement supplementaryView: IXScrollView.SupplementaryView, ofKind kind: IXScrollView.SupplementaryElementKind) {
130 | supplementaryView.indicator.stopAnimation(self)
131 | supplementaryView.indicator.isIndeterminate = false
132 | }
133 |
134 | }
135 |
136 |
137 | // MARK: - Extra function for protocol
138 |
139 |
140 | extension IXScrollViewRefreshable where Self: IXScrollView {
141 |
142 | private func scrollToTop(_ completion: (() -> Void)?) {
143 |
144 | NSAnimationContext.runAnimationGroup({ context in
145 | context.duration = 0.6
146 | context.timingFunction = CAMediaTimingFunction(controlPoints: 0.23, 1, 0.32, 1)
147 | contentView.animator().setBoundsOrigin(NSMakePoint(0, -supplementalRefreshViewHeight))
148 | }, completionHandler: completion)
149 |
150 | }
151 |
152 | private func scrollToBottom() {
153 |
154 | }
155 |
156 | private func scrollToCurrentPointIfPossible() {
157 |
158 | }
159 |
160 | func beginRefreshing(scrollToTop shouldScrollToTop: Bool = false) {
161 | if _isRefreshing { return }
162 | _isRefreshing = true
163 |
164 | oldDocumentHeight = documentHeight
165 |
166 | if shouldScrollToTop {
167 | scrollToTop {
168 | self.ixScrollView(self, didTriggerSupplementaryElement: self.supplementalRefreshView!, ofKind: .refresh)
169 | self.performAction(for: .refresh)
170 | }
171 | } else {
172 | ixScrollView(self, didTriggerSupplementaryElement: supplementalRefreshView!, ofKind: .refresh)
173 | performAction(for: .refresh)
174 | }
175 | }
176 |
177 | func stopRefreshing(scrollToTop shouldScrollToTop: Bool = false) {
178 |
179 | // TODO: FIX ME!
180 | // if set _isRefreshing early, scrolling after animation stop will be normal, but animation will be broken.
181 | // if set _isRefreshing in completion, animation will be normal, but scrolling will be broken.
182 | // it's all about clipview's document height.
183 | // if can find a way to manually update it, this may be fixed.
184 | // so the question here is how.
185 |
186 | if visibleY <= 0 {
187 | if !shouldScrollToTop {
188 | if oldVisibleY <= 0 {
189 | let reversedY = documentHeight - oldDocumentHeight + visibleY
190 | if abs(oldVisibleY) < abs(reversedY) {
191 | self._isRefreshing = false
192 | }
193 | }
194 | }
195 | }
196 |
197 | NSAnimationContext.runAnimationGroup({ _ in
198 | if visibleY <= 0 {
199 | if shouldScrollToTop {
200 | scrollToTop(nil)
201 | } else {
202 | if oldVisibleY <= 0 {
203 | let reversedY = documentHeight - oldDocumentHeight + visibleY
204 |
205 | if abs(oldVisibleY) < abs(reversedY) {
206 | let newOrigin = NSMakePoint(0, reversedY)
207 | contentView.setBoundsOrigin(newOrigin)
208 | } else {
209 | contentView.animator().setBoundsOrigin(.zero)
210 | }
211 | }
212 | }
213 | }
214 | }) {
215 | self._isRefreshing = false
216 | self.ixScrollView(self, didStopSupplementaryElement: self.supplementalRefreshView!, ofKind: .refresh)
217 | }
218 |
219 | }
220 |
221 | func beginLoading() {
222 | if _isLoading { return }
223 | _isLoading = true
224 |
225 | ixScrollView(self, didTriggerSupplementaryElement: supplementalLoadingView!, ofKind: .load)
226 | performAction(for: .load)
227 | }
228 |
229 | func stopLoading(scrollToBottom shouldScrollToBottom: Bool = false) {
230 | let y = self.documentVisibleRect.origin.y
231 | let h = self.documentVisibleRect.size.height
232 | let dh = self.documentView?.frame.size.height ?? h
233 |
234 | NSAnimationContext.runAnimationGroup({ _ in
235 | if y > h { contentView.animator().setBoundsOrigin(NSMakePoint(0, dh - h)) }
236 | }) {
237 | self._isLoading = false
238 | self.ixScrollView(self, didStopSupplementaryElement: self.supplementalLoadingView!, ofKind: .load)
239 | }
240 | }
241 |
242 | private func performAction(for kind: SupplementaryElementKind) {
243 | if kind == .refresh {
244 | if let action = refreshAction { NSApp.sendAction(action, to: target, from: self) }
245 | } else {
246 | if let action = loadAction { NSApp.sendAction(action, to: target, from: self) }
247 | }
248 | }
249 | }
250 |
251 | // MARK: -
252 |
253 | class IXScrollView: NSScrollView, IXScrollViewRefreshable {
254 |
255 | // MARK: Internal Class & Enum
256 |
257 | // A enum indicate the kind of supplementary view.
258 | enum SupplementaryElementKind {
259 |
260 | /// A kind of view to display when pulling from top.
261 | case refresh
262 |
263 | /// A kind of view to display when pulling from bottom.
264 | case load
265 | }
266 |
267 | // A set of behavior of how scroll view should be triggered when pulling from the edge.
268 | enum SupplementaryTriggerBehavior {
269 |
270 | /// Scroll view is triggered as soon as it hit the trigger rect.
271 | case instant
272 |
273 | /// Scroll view is triggered if it's inside trigger rect when finger release.
274 | case overThreshold
275 | }
276 |
277 | /// The class scroll view to display when pulling from egde. This is used by default to show the progress indicator.
278 | /// Subclass this if you want to provide a custom one. Or you can provide a NSView subclass if you want.
279 | class SupplementaryView: NSView {
280 |
281 | /// The indicator to show the current status.
282 | lazy var indicator: NSProgressIndicator = {
283 | let indicator = NSProgressIndicator()
284 | indicator.frame = NSMakeRect(0, 0, 20, 20)
285 | indicator.isDisplayedWhenStopped = true
286 | indicator.isIndeterminate = false
287 | indicator.style = .spinning
288 | indicator.maxValue = 100
289 | indicator.minValue = 0
290 | indicator.doubleValue = 0
291 | indicator.translatesAutoresizingMaskIntoConstraints = false
292 | return indicator
293 | }()
294 |
295 | override init(frame frameRect: NSRect) {
296 | super.init(frame: frameRect)
297 | }
298 |
299 | required init?(coder decoder: NSCoder) {
300 | super.init(coder: decoder)
301 | }
302 |
303 | init(withIndicator createIndicator: Bool) {
304 | super.init(frame: .zero)
305 | if createIndicator {
306 | setupSupplementaryView()
307 | }
308 | }
309 |
310 | /// Setup indicator if needed. This is call only when you using init(withIndicator:) and set it to true.
311 | /// Which mean, if you want to create you own supplementary view, you can set it to false.
312 | func setupSupplementaryView() {
313 | addSubview(indicator)
314 |
315 | NSLayoutConstraint.activate([
316 | indicator.centerXAnchor.constraint(equalTo: centerXAnchor),
317 | indicator.centerYAnchor.constraint(equalTo: centerYAnchor),
318 | indicator.heightAnchor.constraint(equalToConstant: 20),
319 | indicator.widthAnchor.constraint(equalToConstant: 20)
320 | ])
321 | }
322 | }
323 |
324 |
325 | // MARK: - Public Properties
326 |
327 |
328 | /// A bool value control whether scroll view is refreshable.
329 | var canPullToRefresh = true {
330 | didSet { updateStoredSupplementaryElement(ofKind: .refresh, by: canPullToRefresh) }
331 | }
332 |
333 | /// A bool value control whether scroll view is loadable.
334 | var canPullToLoad = false {
335 | didSet { updateStoredSupplementaryElement(ofKind: .load, by: canPullToLoad) }
336 | }
337 |
338 | /// A bool value indicate whether scroll view is refreshing.
339 | var isRefreshing: Bool { return _isRefreshing }
340 |
341 | /// A bool value indicate whether scroll view is loading.
342 | var isLoading: Bool { return _isLoading }
343 |
344 | /// Perform Haptic Feedback when reach the trigger threshold.
345 | var triggeredWithHapticFeedback = true
346 |
347 | // Target & Actions
348 | var target: AnyObject?
349 | var refreshAction: Selector?
350 | var loadAction: Selector?
351 |
352 |
353 | // MARK: - Private Properties
354 |
355 |
356 | /// The delegate of IXScrollViewRefreshable.
357 | fileprivate lazy var refreshableDelegate: IXScrollViewRefreshable = self
358 |
359 | /// Height of refresh view.
360 | fileprivate var supplementalRefreshViewHeight: CGFloat = 0
361 |
362 | /// This control how scroll view should be trigger when pull from top. See SupplementaryTriggerBehavior for more info about different behavior.
363 | fileprivate var supplementalRefreshViewTriggerBehavior: SupplementaryTriggerBehavior = .overThreshold
364 |
365 | /// The view to display when pull from top.
366 | fileprivate var supplementalRefreshView: SupplementaryView? {
367 | didSet {
368 | // add view to scroll view with constraints.
369 | placeSupplementalElement(supplementalRefreshView, ofKindToContentViewIfNeeded: .refresh)
370 | }
371 | }
372 |
373 | /// height of loading view.
374 | fileprivate var supplementalLoadingViewHeight: CGFloat = 0
375 |
376 | /// This control how scroll view should be trigger when pull from bottom. See SupplementaryTriggerBehavior for more info about different behavior.
377 | fileprivate var supplementalLoadingViewTriggerBehavior: SupplementaryTriggerBehavior = .overThreshold
378 |
379 | /// The view to display when pull from bottom.
380 | fileprivate var supplementalLoadingView: SupplementaryView? {
381 | didSet {
382 | // add view to scroll view with constraints.
383 | placeSupplementalElement(supplementalLoadingView, ofKindToContentViewIfNeeded: .load)
384 | }
385 | }
386 |
387 | fileprivate var _progress: CGFloat = 0 {
388 | willSet {
389 | lastProgress = _progress
390 | }
391 |
392 | didSet {
393 | // ask delegate to update
394 | if _progress >= 1 { refreshableDelegate.ixScrollView(self, updateSupplementaryElement: supplementalRefreshView!, ofKind: .refresh, withProgress: progress) }
395 | if _progress <= -1 { refreshableDelegate.ixScrollView(self, updateSupplementaryElement: supplementalLoadingView!, ofKind: .load, withProgress: -progress) }
396 |
397 | // perform Haptic Feedback if needed
398 | performHapticFeedbackIfNeeded()
399 | }
400 | }
401 |
402 | fileprivate var progress: CGFloat {
403 | get {
404 | return _progress + (_progress > 0 ? -1 : 1)
405 | }
406 |
407 | set {
408 | _progress = newValue + (newValue >= 0 ? 1 : -1)
409 | }
410 | }
411 |
412 | /// A Float value storing last progress value.
413 | fileprivate var lastProgress: CGFloat = 0
414 |
415 |
416 | // MARK: - Init
417 |
418 |
419 | override init(frame frameRect: NSRect) {
420 | super.init(frame: frameRect)
421 | setupSupplementalViews()
422 | }
423 |
424 | required init?(coder: NSCoder) {
425 | super.init(coder: coder)
426 | setupSupplementalViews()
427 | }
428 |
429 | // Setup supplemental views if needed.
430 | private func setupSupplementalViews() {
431 |
432 | askDelegateForSupplementalRefreshViewIfNeeded()
433 | askDelegateForSupplementalLoadingViewIfNeeded()
434 |
435 | observeBoundsChangedNotificationsIfNeeded()
436 |
437 | scroll(NSMakePoint(contentView.frame.origin.x, 0))
438 | }
439 |
440 | deinit {
441 | NotificationCenter.default.removeObserver(self, name: NSView.boundsDidChangeNotification, object: contentView)
442 | }
443 |
444 |
445 | // MARK: - Overrides
446 |
447 |
448 | override var documentView: NSView? {
449 | didSet {
450 | askDelegateForSupplementalRefreshViewIfNeeded()
451 | askDelegateForSupplementalLoadingViewIfNeeded()
452 | }
453 | }
454 |
455 | /// Change default clip view to custom one.
456 | override var contentView: NSClipView {
457 | get {
458 | var superClipView = super.contentView
459 |
460 | // if the clip view is not our custom one...
461 | if !superClipView.isKind(of: IXClipView.self), let documentView = superClipView.documentView {
462 |
463 | // create our custom clip view
464 | let clipView = IXClipView(original: superClipView)
465 | clipView.frame = superClipView.frame
466 | clipView.documentView = documentView
467 |
468 | // then set it as the clipview to use
469 | self.contentView = clipView
470 |
471 | superClipView = clipView
472 | }
473 |
474 | return superClipView
475 | }
476 |
477 | set {
478 | super.contentView = newValue
479 | }
480 | }
481 |
482 | // TODO: Maybe an option to allow one action at a time?
483 | /// Hijack scroll wheel event to trigger action when finger leave
484 | override func scrollWheel(with event: NSEvent) {
485 |
486 | // when finger leave trackpad...
487 | if event.phase == .ended {
488 | if canPullToRefresh && !_isRefreshing {
489 | stopReceivingBoundsChanged = false
490 |
491 | if supplementalRefreshViewTriggerBehavior == .overThreshold && _progress >= 2 {
492 | beginRefreshing()
493 | }
494 | }
495 |
496 | if canPullToLoad && !_isLoading {
497 | stopReceivingBoundsChanged = false
498 |
499 | if supplementalLoadingViewTriggerBehavior == .overThreshold && _progress <= -2 {
500 | beginLoading()
501 | }
502 | }
503 | }
504 |
505 | super.scrollWheel(with: event)
506 | }
507 |
508 |
509 | // MARK: - Callbacks
510 |
511 |
512 | /// A bool indicate whether scroll view once pass the trigger rect.
513 | fileprivate var didTriggered: Bool = false
514 |
515 | /// A mutex value to prevent triggered action being call multiple time.
516 | fileprivate var stopReceivingBoundsChanged: Bool = false
517 |
518 | fileprivate var isPullingFromTop: Bool {
519 | return visibleY <= 0
520 | }
521 |
522 | fileprivate var isPullingFromBottom: Bool {
523 | return visibleY + visibleHeight >= documentHeight
524 | }
525 |
526 | /// Callback function when the bounds of scroll view's content view changed.
527 | /// 3 things to do here: 1) update the pulling progress 2) perform Haptic Feedback and 3) call trigger action.
528 | @objc fileprivate func viewBoundsChanged(_ notification: Notification) {
529 | if documentHeight == oldDocumentHeight {
530 | oldVisibleY = visibleY
531 | }
532 |
533 | // 1) Update pulling progress and update supplementary view.
534 | // 2) Perform Haptic Feedback if needed. (see `_progress`'s `didSet`)
535 | updatePullingProgressIfNeeded()
536 |
537 | // 3) Trigger action if needed.
538 | triggerInstantActionIfNeeded()
539 |
540 | }
541 |
542 |
543 | // MARK: - Helper properties & functions to cleanup code above
544 |
545 |
546 | /// The y offset from document view's top.
547 | fileprivate var visibleY: CGFloat {
548 | return documentVisibleRect.origin.y
549 | }
550 |
551 | /// The height of visible content view.
552 | fileprivate var visibleHeight: CGFloat {
553 | return contentView.frame.size.height
554 | }
555 |
556 | fileprivate var oldDocumentHeight: CGFloat = 0
557 | fileprivate var oldVisibleY: CGFloat = 0
558 |
559 | /// The height of document view.
560 | fileprivate var documentHeight: CGFloat {
561 | return documentView?.frame.size.height ?? visibleHeight
562 | }
563 |
564 | /// A bool value indicate whether visible rect reach refresh view's trigger rect.
565 | fileprivate var overRefreshView: Bool {
566 | return visibleY <= -supplementalRefreshViewHeight
567 | }
568 |
569 | /// A bool value indicate whether visible rect reach loading view's trigger rect.
570 | fileprivate var overLoadingView: Bool {
571 | return visibleY + visibleHeight >= documentHeight + supplementalLoadingViewHeight
572 | }
573 |
574 | /// A bool value indicate whether scroll view is refreshing.
575 | fileprivate var _isRefreshing = false
576 |
577 | /// A bool value indicate whether scroll view is loading.
578 | fileprivate var _isLoading = false
579 |
580 | private func askDelegateForSupplementalRefreshViewIfNeeded() {
581 | if canPullToRefresh {
582 | supplementalRefreshViewHeight = refreshableDelegate.ixScrollView(self, heightOfSupplementaryElementOfKind: .refresh)
583 | supplementalRefreshView = refreshableDelegate.ixScrollView(self, viewForSupplementaryElementOfKind: .refresh)
584 | supplementalRefreshViewTriggerBehavior = refreshableDelegate.ixScrollView(self, triggerBehaviorOfSupplementaryElementOfKind: .refresh)
585 | }
586 | }
587 |
588 | private func askDelegateForSupplementalLoadingViewIfNeeded() {
589 | if canPullToLoad {
590 | supplementalLoadingViewHeight = refreshableDelegate.ixScrollView(self, heightOfSupplementaryElementOfKind: .load)
591 | supplementalLoadingView = refreshableDelegate.ixScrollView(self, viewForSupplementaryElementOfKind: .load)
592 | supplementalLoadingViewTriggerBehavior = refreshableDelegate.ixScrollView(self, triggerBehaviorOfSupplementaryElementOfKind: .load)
593 | }
594 | }
595 |
596 | private func observeBoundsChangedNotificationsIfNeeded() {
597 | if canPullToRefresh || canPullToLoad {
598 | NotificationCenter.default.addObserver(self, selector: #selector(viewBoundsChanged(_:)), name: NSView.boundsDidChangeNotification, object: contentView)
599 | contentView.postsBoundsChangedNotifications = true
600 | } else {
601 | contentView.postsBoundsChangedNotifications = false
602 | NotificationCenter.default.removeObserver(self, name: NSView.boundsDidChangeNotification, object: contentView)
603 | }
604 | }
605 |
606 | private func placeSupplementalElement(_ view: SupplementaryView?, ofKindToContentViewIfNeeded kind: SupplementaryElementKind) {
607 | if let view = view, let documentView = documentView {
608 | contentView.addSubview(view)
609 |
610 | let height = kind == .refresh ? supplementalRefreshViewHeight : supplementalLoadingViewHeight
611 |
612 | NSLayoutConstraint.activate([
613 | view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
614 | view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
615 | view.heightAnchor.constraint(equalToConstant: height)
616 | ])
617 |
618 | if kind == .refresh {
619 | view.bottomAnchor.constraint(equalTo: documentView.topAnchor).isActive = true
620 | } else {
621 | view.topAnchor.constraint(equalTo: documentView.bottomAnchor).isActive = true
622 | }
623 | }
624 | }
625 |
626 | private func updateStoredSupplementaryElement(ofKind kind: SupplementaryElementKind, by flag: Bool) {
627 | if flag {
628 | if kind == .refresh {
629 | askDelegateForSupplementalRefreshViewIfNeeded()
630 | } else {
631 | askDelegateForSupplementalLoadingViewIfNeeded()
632 | }
633 | } else {
634 | if kind == .refresh {
635 | supplementalRefreshView = nil
636 | } else {
637 | supplementalLoadingView = nil
638 | }
639 | }
640 | }
641 |
642 | private func updatePullingProgressIfNeeded() {
643 | // if is pulling from top...
644 | if canPullToRefresh && isPullingFromTop {
645 | // calculate current progress
646 | progress = -visibleY / supplementalRefreshViewHeight
647 | // or is pulling from bottom...
648 | } else if canPullToLoad && isPullingFromBottom {
649 | // calculate current progress
650 | progress = (visibleY + visibleHeight - documentHeight) / -supplementalLoadingViewHeight
651 | }
652 | }
653 |
654 | private func performHapticFeedbackIfNeeded() {
655 | // are we allowed to perform Haptic Feedback?
656 | if triggeredWithHapticFeedback {
657 | // are we being refreshing or loading?
658 | if !_isRefreshing && !_isLoading {
659 | // did it pass the trigger threshold?
660 | if (abs(lastProgress) >= 2 && abs(progress) < 1) || (abs(lastProgress) < 2 && abs(progress) >= 1) {
661 | // perform Haptic Feedback
662 | NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .drawCompleted)
663 | }
664 | }
665 | }
666 | }
667 |
668 | private func triggerInstantActionIfNeeded() {
669 | // is it already been triggered once?
670 | guard !stopReceivingBoundsChanged else { return }
671 |
672 | // trying to trigger refreshing
673 | if visibleY <= -supplementalRefreshViewHeight {
674 | stopReceivingBoundsChanged = true
675 | didTriggered = true
676 |
677 | // begin action if needed
678 | if supplementalRefreshViewTriggerBehavior == .instant {
679 | beginRefreshing()
680 | }
681 |
682 | // trying to trigger loading
683 | } else if visibleY + visibleHeight >= documentHeight + supplementalLoadingViewHeight {
684 | stopReceivingBoundsChanged = true
685 | didTriggered = true
686 |
687 | // begin action if needed
688 | if supplementalLoadingViewTriggerBehavior == .instant {
689 | beginLoading()
690 | }
691 | }
692 | }
693 | }
694 |
695 |
696 | // MARK: - Custom Clip View
697 |
698 | class IXClipView: NSClipView {
699 |
700 | // MARK: - Init
701 |
702 | init(original clipView: NSClipView) {
703 | super.init(frame: clipView.frame)
704 |
705 | autoresizingMask = clipView.autoresizingMask
706 | autoresizesSubviews = clipView.autoresizesSubviews
707 | backgroundColor = clipView.backgroundColor
708 | translatesAutoresizingMaskIntoConstraints = clipView.translatesAutoresizingMaskIntoConstraints
709 | copiesOnScroll = clipView.copiesOnScroll
710 | }
711 |
712 | required init?(coder decoder: NSCoder) {
713 | super.init(coder: decoder)
714 | }
715 |
716 | // MARK: - Super View's Properties
717 |
718 | fileprivate var supplementalRefreshView: NSView? {
719 | return (superview as? IXScrollView)?.supplementalRefreshView
720 | }
721 |
722 | var supplementalLoadingView: NSView? {
723 | return (superview as? IXScrollView)?.supplementalLoadingView
724 | }
725 |
726 | fileprivate var isRefreshing: Bool {
727 | return (superview as? IXScrollView)?._isRefreshing ?? false
728 | }
729 |
730 | fileprivate var isLoading: Bool {
731 | return (superview as? IXScrollView)?._isLoading ?? false
732 | }
733 |
734 | // MARK: - Overrides
735 |
736 | /// Update document rect when refreshing or loading to keep supplementary display without hidding.
737 | override var documentRect: NSRect {
738 | var rect = super.documentRect
739 |
740 | if isRefreshing {
741 | let height = supplementalRefreshView?.frame.size.height ?? 0
742 | rect.size.height += height
743 | rect.origin.y -= height
744 | }
745 |
746 | if isLoading {
747 | let height = supplementalLoadingView?.frame.size.height ?? 0
748 | rect.size.height += height
749 | }
750 |
751 | return rect
752 | }
753 |
754 | override var isFlipped: Bool {
755 | return true
756 | }
757 | }
758 |
--------------------------------------------------------------------------------
/IXRefreshableScrollView/IXRefreshableScrollView/Base.lproj/Main.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
674 |
675 |
676 |
677 |
678 |
679 |
680 |
681 |
682 |
683 |
684 |
685 |
686 |
687 |
688 |
689 |
690 |
691 |
692 |
693 |
694 |
695 |
696 |
697 |
698 |
699 |
700 |
701 |
702 |
703 |
704 |
705 |
706 |
707 |
708 |
709 |
710 |
711 |
712 |
713 |
714 |
715 |
716 |
717 |
718 |
719 |
720 |
721 |
722 |
723 |
724 |
725 |
726 |
727 |
728 |
729 |
730 |
731 |
732 |
733 |
734 |
735 |
736 |
737 |
738 |
739 |
740 |
741 |
742 |
743 |
744 |
745 |
746 |
747 |
748 |
749 |
750 |
751 |
752 |
753 |
754 |
755 |
756 |
757 |
758 |
759 |
760 |
761 |
762 |
763 |
764 |
765 |
766 |
767 |
768 |
769 |
770 |
771 |
772 |
773 |
774 |
775 |
776 |
786 |
796 |
806 |
813 |
820 |
827 |
828 |
829 |
830 |
831 |
832 |
833 |
834 |
835 |
836 |
837 |
838 |
839 |
840 |
841 |
842 |
843 |
844 |
845 |
846 |
847 |
848 |
849 |
850 |
851 |
852 |
853 |
854 |
855 |
856 |
857 |
858 |
859 |
860 |
861 |
862 |
863 |
864 |
865 |
866 |
867 |
868 |
869 |
870 |
871 |
872 |
873 |
874 |
875 |
876 |
877 |
878 |
879 |
880 |
881 |
882 |
883 |
--------------------------------------------------------------------------------