├── ScrollEdgeControl-Demo ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Info.plist ├── AppDelegate.swift ├── BookContainerViewController.swift ├── DemoUIRefreshViewController.swift ├── DebuggingRefreshIndicatorView.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── UIControl+Closure.swift ├── Book.swift ├── Components.swift ├── DemoVerticalViewController.swift ├── DemoVerticalStickyHeaderViewController.swift └── DemoHorizontalViewController.swift ├── ScrollEdgeControl.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── xcshareddata │ └── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist └── project.pbxproj ├── ScrollEdgeControl ├── Core │ ├── UIView+Frame.swift │ ├── ScrollStickyVerticalHeaderView.swift │ └── ScrollEdgeControl.swift └── Library │ ├── ScrollEdgeActivityIndicatorView.swift │ └── DonutsIndicatorView.swift ├── .github └── workflows │ └── Checks.yml ├── Package.swift ├── ScrollEdgeControl.podspec ├── LICENSE ├── .gitignore └── README.md /ScrollEdgeControl-Demo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ScrollEdgeControl.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ScrollEdgeControl/Core/UIView+Frame.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIView { 4 | 5 | func resetCenter() { 6 | 7 | let center = CGPoint(x: bounds.midX, y: bounds.midY) 8 | 9 | guard self.center != center else { return } 10 | self.center = center 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /ScrollEdgeControl.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/Checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | push: 5 | branches: "*" 6 | pull_request: 7 | branches: "*" 8 | 9 | jobs: 10 | pod-lint: 11 | runs-on: macos-11 12 | 13 | steps: 14 | - uses: maxim-lobanov/setup-xcode@v1.1 15 | with: 16 | xcode-version: "13.1" 17 | - uses: actions/checkout@v2 18 | - name: Run lint 19 | run: pod lib lint --allow-warnings 20 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | var window: UIWindow? 7 | 8 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 9 | 10 | let newWindow = UIWindow() 11 | newWindow.rootViewController = RootContainerViewController() 12 | newWindow.makeKeyAndVisible() 13 | self.window = newWindow 14 | return true 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/BookContainerViewController.swift: -------------------------------------------------------------------------------- 1 | import StorybookKit 2 | import StorybookUI 3 | import UIKit 4 | 5 | final class RootContainerViewController: UIViewController { 6 | 7 | init() { 8 | super.init(nibName: nil, bundle: nil) 9 | 10 | let child = StorybookViewController( 11 | book: book, 12 | dismissHandler: nil 13 | ) 14 | 15 | addChild(child) 16 | view.addSubview(child.view) 17 | child.view.frame = view.bounds 18 | child.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 19 | 20 | } 21 | 22 | required init?(coder: NSCoder) { 23 | fatalError("init(coder:) has not been implemented") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "ScrollEdgeControl", 6 | platforms: [.iOS(.v13)], 7 | products: [ 8 | .library(name: "ScrollEdgeControl", type: .static, targets: ["ScrollEdgeControl"]), 9 | .library(name: "ScrollEdgeControlComponents", type: .static, targets: ["ScrollEdgeControlComponents"]), 10 | ], 11 | dependencies: [ 12 | .package(url: "http://github.com/timdonnelly/Advance", from: "3.0.0") 13 | ], 14 | targets: [ 15 | .target( 16 | name: "ScrollEdgeControl", 17 | dependencies: ["Advance"], 18 | path: "ScrollEdgeControl/Core" 19 | ), 20 | 21 | .target( 22 | name: "ScrollEdgeControlComponents", 23 | dependencies: ["ScrollEdgeControl"], 24 | path: "ScrollEdgeControl/Library" 25 | ) 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/DemoUIRefreshViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import MondrianLayout 4 | import StorybookUI 5 | import StackScrollView 6 | import ScrollEdgeControl 7 | 8 | final class DemoUIRefreshViewController: UIViewController { 9 | 10 | private let scrollView = StackScrollView() 11 | 12 | init() { 13 | 14 | super.init(nibName: nil, bundle: nil) 15 | 16 | } 17 | 18 | required init?(coder: NSCoder) { 19 | fatalError("init(coder:) has not been implemented") 20 | } 21 | 22 | override func viewDidLoad() { 23 | super.viewDidLoad() 24 | let scrollView = self.scrollView 25 | 26 | view.backgroundColor = .white 27 | 28 | view.mondrian.buildSubviews { 29 | HStackBlock { 30 | scrollView 31 | } 32 | } 33 | 34 | let cells = (0..<(20)).map { _ in 35 | Components.makeDemoCell() 36 | } 37 | 38 | scrollView.append(views: cells) 39 | 40 | let control = UIRefreshControl() 41 | 42 | scrollView.addSubview(control) 43 | 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /ScrollEdgeControl.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = "ScrollEdgeControl" 4 | s.version = "1.2.0" 5 | s.summary = "Yet another UIRefreshControl" 6 | s.description = <<-DESC 7 | Yet another UIRefreshControl. It's control for edge in scroll view. 8 | DESC 9 | 10 | s.homepage = "http://github.com/eure/ScrollEdgeControl" 11 | s.license = "MIT" 12 | s.author = { "Muukii" => "hiroshi.kimura@eure.jp", "Matto" => "takuma.matsushita@eure.jp" } 13 | s.ios.deployment_target = "12.0" 14 | s.source = { :git => "https://github.com/eure/ScrollEdgeControl.git", :tag => "#{s.version}" } 15 | s.dependency "Advance" 16 | s.default_subspec = "Library" 17 | s.swift_versions = ["5.4", "5.5"] 18 | 19 | s.subspec "Core" do |ss| 20 | ss.source_files = "ScrollEdgeControl/Core/**/*.swift" 21 | end 22 | 23 | s.subspec "Library" do |ss| 24 | ss.source_files = "ScrollEdgeControl/Library/**/*.swift" 25 | ss.dependency "ScrollEdgeControl/Core" 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Eureka, Inc. 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 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/DebuggingRefreshIndicatorView.swift: -------------------------------------------------------------------------------- 1 | 2 | import MondrianLayout 3 | import UIKit 4 | import ScrollEdgeControl 5 | 6 | public final class DebuggingRefreshIndicatorView: UIView, ScrollEdgeActivityIndicatorViewType { 7 | 8 | private let label = UILabel() 9 | 10 | public init() { 11 | super.init(frame: .zero) 12 | 13 | let backgroundView = UIView() 14 | backgroundView.backgroundColor = .darkGray 15 | 16 | mondrian.buildSubviews { 17 | ZStackBlock { 18 | label 19 | .viewBlock 20 | .padding(4) 21 | .background(backgroundView) 22 | } 23 | } 24 | 25 | label.textColor = .white 26 | label.font = .systemFont(ofSize: 8) 27 | } 28 | 29 | required init?( 30 | coder: NSCoder 31 | ) { 32 | fatalError() 33 | } 34 | 35 | public func update(withState state: ScrollEdgeControl.ActivatingState) { 36 | 37 | switch state { 38 | case .triggering(let progress): 39 | label.text = "triggering \(progress)" 40 | case .active: 41 | label.text = "refreshing" 42 | case .completed: 43 | label.text = "completed" 44 | } 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/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 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/UIControl+Closure.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @MainActor 4 | private final class Proxy { 5 | 6 | static var key: Void? 7 | private weak var base: UIControl? 8 | 9 | init(_ base: UIControl) { 10 | self.base = base 11 | } 12 | 13 | var onTouchUpInside: (@MainActor () -> Void)? { 14 | didSet { 15 | base?.addTarget( 16 | self, 17 | action: #selector(touchUpInside(sender:)), 18 | for: .touchUpInside 19 | ) 20 | } 21 | } 22 | 23 | var onValueChanged: (@MainActor () -> Void)? { 24 | didSet { 25 | base?.addTarget( 26 | self, 27 | action: #selector(valueChanged(sender:)), 28 | for: .valueChanged 29 | ) 30 | } 31 | } 32 | 33 | @objc private dynamic func touchUpInside(sender: AnyObject) { 34 | onTouchUpInside?() 35 | } 36 | 37 | @objc private dynamic func valueChanged(sender: AnyObject) { 38 | onValueChanged?() 39 | } 40 | } 41 | 42 | extension UIControl { 43 | 44 | /// [Local extension] 45 | public func onTap(_ closure: @MainActor @escaping () -> Swift.Void) { 46 | proxy.onTouchUpInside = closure 47 | } 48 | 49 | public func onValueChanged(_ closure: @MainActor @escaping () -> Swift.Void) { 50 | proxy.onValueChanged = closure 51 | } 52 | 53 | private var proxy: Proxy { 54 | get { 55 | if let handler = objc_getAssociatedObject(self, &Proxy.key) as? Proxy { 56 | return handler 57 | } else { 58 | self.proxy = Proxy(self) 59 | return self.proxy 60 | } 61 | } 62 | set { 63 | objc_setAssociatedObject(self, &Proxy.key, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 64 | } 65 | } 66 | } 67 | 68 | extension UIButton { 69 | static func make(title: String, _ onTap: @escaping () -> Void) -> UIButton { 70 | let button = UIButton(frame: .zero) 71 | button.setTitle(title, for: .normal) 72 | button.onTap(onTap) 73 | return button 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /ScrollEdgeControl.xcodeproj/xcshareddata/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 16 | 17 | 19 | 21 | 22 | 23 | 24 | 25 | 33 | 34 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/Book.swift: -------------------------------------------------------------------------------- 1 | import StorybookKit 2 | 3 | let book = Book(title: "MyBook") { 4 | BookNavigationLink(title: "Vertical") { 5 | 6 | let Controller = DemoVerticalViewController.self 7 | 8 | BookSection(title: "Pull to refresh") { 9 | BookPush(title: "Top") { 10 | Controller.init( 11 | configuration: .init(startSideConfiguration: .init(), endSideConfiguration: nil) 12 | ) 13 | } 14 | BookPush(title: "Bottom") { 15 | Controller.init( 16 | configuration: .init(startSideConfiguration: nil, endSideConfiguration: .init()) 17 | ) 18 | } 19 | BookPush(title: "Both") { 20 | Controller.init( 21 | configuration: .init(startSideConfiguration: .init(), endSideConfiguration: .init()) 22 | ) 23 | } 24 | } 25 | 26 | BookSection(title: "Pull to refresh and tail loading") { 27 | BookPush(title: "Example") { 28 | let controller = Controller.init( 29 | configuration: .init( 30 | startSideConfiguration: .init(), 31 | endSideConfiguration: .init { 32 | $0.layoutMode = .scrollingAlongContent 33 | $0.pullToActivateMode = .disabled 34 | } 35 | ) 36 | ) 37 | 38 | controller.endScrollEdgeControl?.setActivityState(.active, animated: false) 39 | 40 | return controller 41 | } 42 | } 43 | 44 | } 45 | 46 | BookNavigationLink(title: "Horizontal") { 47 | 48 | let Controller = DemoHorizontalViewController.self 49 | 50 | BookPush(title: "Top") { 51 | Controller.init( 52 | configuration: .init(startSideConfiguration: .init(), endSideConfiguration: nil) 53 | ) 54 | } 55 | BookPush(title: "Bottom") { 56 | Controller.init( 57 | configuration: .init(startSideConfiguration: nil, endSideConfiguration: .init()) 58 | ) 59 | } 60 | BookPush(title: "Both") { 61 | Controller.init( 62 | configuration: .init(startSideConfiguration: .init(), endSideConfiguration: .init()) 63 | ) 64 | } 65 | } 66 | 67 | BookNavigationLink(title: "Sticky") { 68 | 69 | BookPush(title: "Vertical") { 70 | DemoVerticalStickyHeaderViewController() 71 | } 72 | 73 | } 74 | 75 | BookNavigationLink(title: "UIRefreshControl") { 76 | BookPush(title: "Vertical") { 77 | DemoUIRefreshViewController() 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/swift 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift 4 | 5 | ### Swift ### 6 | # Xcode 7 | # 8 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 9 | 10 | ## User settings 11 | xcuserdata/ 12 | 13 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 14 | *.xcscmblueprint 15 | *.xccheckout 16 | 17 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 18 | build/ 19 | DerivedData/ 20 | *.moved-aside 21 | *.pbxuser 22 | !default.pbxuser 23 | *.mode1v3 24 | !default.mode1v3 25 | *.mode2v3 26 | !default.mode2v3 27 | *.perspectivev3 28 | !default.perspectivev3 29 | 30 | ## Obj-C/Swift specific 31 | *.hmap 32 | 33 | ## App packaging 34 | *.ipa 35 | *.dSYM.zip 36 | *.dSYM 37 | 38 | ## Playgrounds 39 | timeline.xctimeline 40 | playground.xcworkspace 41 | 42 | # Swift Package Manager 43 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 44 | # Packages/ 45 | # Package.pins 46 | # Package.resolved 47 | # *.xcodeproj 48 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 49 | # hence it is not needed unless you have added a package configuration file to your project 50 | # .swiftpm 51 | 52 | .build/ 53 | 54 | # CocoaPods 55 | # We recommend against adding the Pods directory to your .gitignore. However 56 | # you should judge for yourself, the pros and cons are mentioned at: 57 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 58 | Pods/ 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 64 | # Carthage/Checkouts 65 | 66 | Carthage/Build/ 67 | 68 | # Accio dependency management 69 | Dependencies/ 70 | .accio/ 71 | 72 | # fastlane 73 | # It is recommended to not store the screenshots in the git repo. 74 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 75 | # For more information about the recommended setup visit: 76 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 77 | 78 | fastlane/report.xml 79 | fastlane/Preview.html 80 | fastlane/screenshots/**/*.png 81 | fastlane/test_output 82 | 83 | # Code Injection 84 | # After new code Injection tools there's a generated folder /iOSInjectionProject 85 | # https://github.com/johnno1962/injectionforxcode 86 | 87 | iOSInjectionProject/ 88 | 89 | .DS_Store 90 | 91 | # End of https://www.toptal.com/developers/gitignore/api/swift 92 | 93 | -------------------------------------------------------------------------------- /ScrollEdgeControl.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "advance", 5 | "kind" : "remoteSourceControl", 6 | "location" : "git@github.com:timdonnelly/Advance.git", 7 | "state" : { 8 | "revision" : "0654131d602c85c58a4c89b2f95ad1ef2894f8fd", 9 | "version" : "3.1.1" 10 | } 11 | }, 12 | { 13 | "identity" : "compositionkit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/muukii/CompositionKit.git", 16 | "state" : { 17 | "revision" : "a3153e6afe3260d71f7b95d48d5cbe913dc450b9", 18 | "version" : "0.2.1" 19 | } 20 | }, 21 | { 22 | "identity" : "descriptors", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/muukii/Descriptors.git", 25 | "state" : { 26 | "revision" : "b93f250d6e4007d512da77b1138acd03c11d4550", 27 | "version" : "0.1.0" 28 | } 29 | }, 30 | { 31 | "identity" : "mondrianlayout", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/muukii/MondrianLayout.git", 34 | "state" : { 35 | "branch" : "main", 36 | "revision" : "239bc0b02b8abbc211720476d1554b736832bb9c" 37 | } 38 | }, 39 | { 40 | "identity" : "rxswift", 41 | "kind" : "remoteSourceControl", 42 | "location" : "git@github.com:ReactiveX/RxSwift.git", 43 | "state" : { 44 | "revision" : "b4307ba0b6425c0ba4178e138799946c3da594f8", 45 | "version" : "6.5.0" 46 | } 47 | }, 48 | { 49 | "identity" : "stackscrollview", 50 | "kind" : "remoteSourceControl", 51 | "location" : "git@github.com:muukii/StackScrollView.git", 52 | "state" : { 53 | "branch" : "master", 54 | "revision" : "c3f00b0d93a8b40173caa39111a15f9488708ef1" 55 | } 56 | }, 57 | { 58 | "identity" : "storybook-ios", 59 | "kind" : "remoteSourceControl", 60 | "location" : "git@github.com:eure/Storybook-ios.git", 61 | "state" : { 62 | "revision" : "48cd0bbecd40edabcce32e819c5a23014a49013b", 63 | "version" : "1.9.0" 64 | } 65 | }, 66 | { 67 | "identity" : "typedtextattributes", 68 | "kind" : "remoteSourceControl", 69 | "location" : "git@github.com:muukii/TypedTextAttributes.git", 70 | "state" : { 71 | "revision" : "0f0986f98f3c81d455a48886561679245890782a", 72 | "version" : "1.3.0" 73 | } 74 | }, 75 | { 76 | "identity" : "verge", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/VergeGroup/Verge", 79 | "state" : { 80 | "revision" : "fd470f3189c52f851553caf13b8b1dd6795207bc", 81 | "version" : "8.15.0" 82 | } 83 | } 84 | ], 85 | "version" : 2 86 | } 87 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/Components.swift: -------------------------------------------------------------------------------- 1 | @testable import ScrollEdgeControl 2 | import MondrianLayout 3 | import UIKit 4 | import CompositionKit 5 | 6 | enum Components { 7 | 8 | static func makeScrollViewDebuggingView(scrollView: UIScrollView) -> UIView { 9 | 10 | let label = UILabel() 11 | label.numberOfLines = 0 12 | label.font = .systemFont(ofSize: 10) 13 | 14 | let handler = { 15 | 16 | label.text = """ 17 | adjustedContentInset: \(scrollView.adjustedContentInset) 18 | contentInset: \(scrollView.contentInset) 19 | contentOffset: \(scrollView.contentOffset) 20 | 21 | local: \(scrollView._scrollEdgeControl_localContentInset) 22 | 23 | actualContentInset: \(scrollView.__original_contentInset) 24 | translation: \(scrollView.panGestureRecognizer.translation(in: nil).y) 25 | """ 26 | } 27 | 28 | let token1 = scrollView.observe(\.contentInset) { _, change in 29 | handler() 30 | } 31 | 32 | let token2 = scrollView.observe(\.contentOffset) { _, change in 33 | handler() 34 | } 35 | 36 | return AnyView.init { _ in 37 | label 38 | .viewBlock 39 | .padding(4) 40 | } 41 | .setOnDeinit { 42 | withExtendedLifetime([token1, token2], {}) 43 | } 44 | 45 | } 46 | 47 | static func makeDemoCell() -> UIView { 48 | let view = UIView() 49 | 50 | view.mondrian.layout 51 | .height(.exact(80, .defaultLow)) 52 | .width(.exact(80, .defaultLow)) 53 | .activate() 54 | 55 | view.backgroundColor = .init(white: 0.9, alpha: 1) 56 | view.layer.borderColor = UIColor(white: 0, alpha: 0.2).cgColor 57 | view.layer.borderWidth = 6 58 | view.layer.cornerRadius = 12 59 | if #available(iOS 13.0, *) { 60 | view.layer.cornerCurve = .continuous 61 | } else { 62 | // Fallback on earlier versions 63 | } 64 | 65 | return AnyView.init { _ in 66 | view 67 | .viewBlock 68 | .padding(4) 69 | } 70 | } 71 | 72 | static func makeSelectionView(title: String, onTap: @escaping () -> Void) -> UIView { 73 | 74 | let button = UIButton(type: .system) 75 | button.setTitle(title, for: .normal) 76 | button.onTap(onTap) 77 | 78 | return AnyView.init { view in 79 | VStackBlock { 80 | button 81 | .viewBlock 82 | .padding(10) 83 | } 84 | } 85 | } 86 | 87 | static func makeStepperView( 88 | title: String, 89 | onIncreased: @escaping () -> Void, 90 | onDecreased: @escaping () -> Void 91 | ) -> UIView { 92 | 93 | let titleLabel = UILabel() 94 | titleLabel.text = title 95 | 96 | let increaseButton = UIButton(type: .system) 97 | increaseButton.setTitle("+", for: .normal) 98 | increaseButton.onTap(onIncreased) 99 | 100 | let decreaseButton = UIButton(type: .system) 101 | decreaseButton.setTitle("-", for: .normal) 102 | decreaseButton.onTap(onDecreased) 103 | 104 | return AnyView.init { view in 105 | VStackBlock { 106 | titleLabel 107 | HStackBlock(spacing: 4) { 108 | increaseButton 109 | .viewBlock 110 | 111 | decreaseButton 112 | .viewBlock 113 | } 114 | } 115 | .padding(10) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /ScrollEdgeControl/Library/ScrollEdgeActivityIndicatorView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public final class ScrollEdgeActivityIndicatorView: UIView, 5 | ScrollEdgeActivityIndicatorViewType 6 | { 7 | 8 | private let fractionIndicator: DonutsIndicatorFractionView 9 | private let donutsIndicator: DonutsIndicatorView 10 | 11 | public init( 12 | color: DonutsIndicatorView.Color 13 | ) { 14 | 15 | fractionIndicator = .init(size: .medium, color: color) 16 | donutsIndicator = .init(size: .medium, color: color) 17 | 18 | super.init(frame: .zero) 19 | 20 | addSubview(fractionIndicator) 21 | addSubview(donutsIndicator) 22 | 23 | } 24 | 25 | @available(*, unavailable) 26 | public required init?(coder: NSCoder) { 27 | fatalError("init(coder:) has not been implemented") 28 | } 29 | 30 | public override func layoutSubviews() { 31 | super.layoutSubviews() 32 | 33 | /** 34 | [Workaround for using Texture] 35 | It should be laid out manually since the ASCollectionNode won't display cells initially in a horizontal scroll by using AutoLayout in the ASCollectionView. 36 | */ 37 | fractionIndicator.center = bounds.center 38 | donutsIndicator.center = bounds.center 39 | fractionIndicator.bounds.size = fractionIndicator.intrinsicContentSize 40 | donutsIndicator.bounds.size = donutsIndicator.intrinsicContentSize 41 | 42 | } 43 | 44 | public func setColor(_ color: DonutsIndicatorView.Color) { 45 | fractionIndicator.setColor(color) 46 | donutsIndicator.setColor(color) 47 | } 48 | 49 | public func update(withState state: ScrollEdgeControl.ActivatingState) { 50 | 51 | switch state { 52 | case .triggering(let progress): 53 | 54 | donutsIndicator.alpha = 0 55 | donutsIndicator.stopAnimating() 56 | 57 | if progress > 0 { 58 | 59 | fractionIndicator.alpha = 1 60 | fractionIndicator.setProgress(progress) 61 | 62 | } else { 63 | 64 | if fractionIndicator.alpha != 0 { 65 | 66 | fractionIndicator.alpha = 0 67 | 68 | let t = CATransition() 69 | t.duration = 0.2 70 | fractionIndicator.layer.add(t, forKey: "fade") 71 | } 72 | } 73 | 74 | case .active: 75 | 76 | donutsIndicator.alpha = 1 77 | donutsIndicator.startAnimating() 78 | 79 | fractionIndicator.alpha = 0 80 | 81 | let t = CATransition() 82 | t.duration = 0.2 83 | 84 | layer.add(t, forKey: "fade") 85 | 86 | case .completed: 87 | 88 | donutsIndicator.stopAnimating() 89 | fractionIndicator.setProgress(0) 90 | fractionIndicator.alpha = 0 91 | 92 | } 93 | 94 | } 95 | 96 | } 97 | 98 | extension ScrollEdgeControl { 99 | 100 | public static func donutsIndicator( 101 | edge: ScrollEdgeControl.Edge, 102 | configuration: ScrollEdgeControl.Configuration, 103 | color: DonutsIndicatorView.Color = .black 104 | ) -> Self { 105 | 106 | let instance = Self.init( 107 | edge: edge, 108 | configuration: configuration, 109 | activityIndicatorView: ScrollEdgeActivityIndicatorView(color: color) 110 | ) 111 | 112 | return instance 113 | } 114 | } 115 | 116 | extension CGRect { 117 | // MARK: Public 118 | 119 | public var center: CGPoint { 120 | return CGPoint(x: self.midX, y: self.midY) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/DemoVerticalViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import MondrianLayout 4 | import StorybookUI 5 | import StackScrollView 6 | import ScrollEdgeControl 7 | 8 | final class DemoVerticalViewController: UIViewController { 9 | 10 | struct Configuration { 11 | var numberOfElements: Int = 5 12 | var startSideConfiguration: ScrollEdgeControl.Configuration? 13 | var endSideConfiguration: ScrollEdgeControl.Configuration? 14 | } 15 | 16 | let startScrollEdgeControl: ScrollEdgeControl? 17 | let endScrollEdgeControl: ScrollEdgeControl? 18 | 19 | private let scrollView = StackScrollView() 20 | private let menuView = StackScrollView() 21 | private let configuration: Configuration 22 | 23 | init(configuration: Configuration) { 24 | 25 | self.configuration = configuration 26 | 27 | if let configuration = configuration.startSideConfiguration { 28 | self.startScrollEdgeControl = ScrollEdgeControl(edge: .top, configuration: configuration, activityIndicatorView: ScrollEdgeActivityIndicatorView(color: .black)) 29 | } else { 30 | self.startScrollEdgeControl = nil 31 | } 32 | 33 | if let configuration = configuration.endSideConfiguration { 34 | self.endScrollEdgeControl = ScrollEdgeControl(edge: .bottom, configuration: configuration, activityIndicatorView: ScrollEdgeActivityIndicatorView(color: .black)) 35 | } else { 36 | self.endScrollEdgeControl = nil 37 | } 38 | 39 | super.init(nibName: nil, bundle: nil) 40 | 41 | } 42 | 43 | required init?(coder: NSCoder) { 44 | fatalError("init(coder:) has not been implemented") 45 | } 46 | 47 | override func viewDidLoad() { 48 | super.viewDidLoad() 49 | let scrollView = self.scrollView 50 | 51 | view.backgroundColor = .white 52 | 53 | view.mondrian.buildSubviews { 54 | HStackBlock { 55 | scrollView 56 | menuView 57 | } 58 | } 59 | 60 | scrollView.mondrian.layout.width(.to(menuView).width).activate() 61 | 62 | menuView.append(views: [ 63 | Components.makeScrollViewDebuggingView(scrollView: scrollView) 64 | ]) 65 | 66 | if let control = startScrollEdgeControl { 67 | scrollView.addSubview(control) 68 | 69 | menuView.append(views: [ 70 | Components.makeSelectionView(title: "Top: Activate", onTap: { 71 | control.setActivityState(.active, animated: true) 72 | }), 73 | Components.makeSelectionView(title: "Top: Deactivate", onTap: { 74 | control.setActivityState(.inactive, animated: true) 75 | }) 76 | ]) 77 | } 78 | 79 | if let control = endScrollEdgeControl { 80 | scrollView.addSubview(control) 81 | 82 | menuView.append(views: [ 83 | Components.makeSelectionView(title: "Bottom: Activate", onTap: { 84 | control.setActivityState(.active, animated: true) 85 | }), 86 | Components.makeSelectionView(title: "Bottom: Deactivate", onTap: { 87 | control.setActivityState(.inactive, animated: true) 88 | }) 89 | ]) 90 | } 91 | 92 | menuView.append(views: [ 93 | Components.makeStepperView( 94 | title: "Inset top", 95 | onIncreased: { 96 | scrollView.contentInset.top += 20 97 | }, 98 | onDecreased: { 99 | scrollView.contentInset.top -= 20 100 | }), 101 | Components.makeStepperView( 102 | title: "Inset bottom", 103 | onIncreased: { 104 | scrollView.contentInset.bottom += 20 105 | }, 106 | onDecreased: { 107 | scrollView.contentInset.bottom -= 20 108 | }) 109 | ]) 110 | 111 | let cells = (0..<(configuration.numberOfElements)).map { _ in 112 | Components.makeDemoCell() 113 | } 114 | 115 | scrollView.append(views: cells) 116 | 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/DemoVerticalStickyHeaderViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import MondrianLayout 4 | import StorybookUI 5 | import StorybookKit 6 | import StackScrollView 7 | import ScrollEdgeControl 8 | import CompositionKit 9 | 10 | final class DemoVerticalStickyHeaderViewController: UIViewController { 11 | 12 | override func viewDidLoad() { 13 | super.viewDidLoad() 14 | 15 | // let edgeControl = ScrollEdgeControl(edge: .top, configuration: .init(), activityIndicatorView: ScrollEdgeActivityIndicatorView(color: .black)) 16 | 17 | let headerView = LongHeaderView() 18 | 19 | let stickyView = ScrollStickyVerticalHeaderView() 20 | 21 | view.backgroundColor = .white 22 | 23 | let scrollView = ScrollableContainerView() 24 | stickyView.setContent(headerView) 25 | 26 | let contentView = UIView() 27 | contentView.backgroundColor = .systemYellow.withAlphaComponent(0.8) 28 | contentView.mondrian.layout.height(1000).activate() 29 | 30 | Mondrian.buildSubviews(on: contentView) { 31 | VStackBlock { 32 | UIButton.make(title: "IsActive") { 33 | stickyView.setIsActive(!stickyView.isActive, animated: true) 34 | } 35 | 36 | UIButton.make(title: "Attaches SafeArea") { 37 | stickyView.configuration.attachesToSafeArea.toggle() 38 | } 39 | 40 | UIButton.make(title: "Short content") { 41 | stickyView.setContent(ShortHeaderView()) 42 | } 43 | 44 | UIButton.make(title: "Long content") { 45 | stickyView.setContent(LongHeaderView()) 46 | } 47 | 48 | StackingSpacer(minLength: 0) 49 | } 50 | } 51 | 52 | scrollView.setContent(contentView) 53 | scrollView.addSubview(stickyView) 54 | // scrollView.addSubview(edgeControl) 55 | scrollView.alwaysBounceVertical = true 56 | 57 | Mondrian.buildSubviews(on: view) { 58 | ZStackBlock(alignment: .attach(.all)) { 59 | scrollView.viewBlock 60 | } 61 | } 62 | 63 | } 64 | 65 | private final class LongHeaderView: CodeBasedView, ScrollStickyContentType { 66 | 67 | private let label = UILabel() 68 | 69 | init() { 70 | 71 | super.init(frame: .null) 72 | 73 | backgroundColor = .systemGray.withAlphaComponent(0.8) 74 | // mondrian.layout.height(100).activate() 75 | 76 | let button = UIButton(type: .system) 77 | button.setTitle("Update", for: .normal) 78 | button.addTarget(self, action: #selector(updateText), for: .primaryActionTriggered) 79 | 80 | label.numberOfLines = 0 81 | 82 | Mondrian.buildSubviews(on: self) { 83 | 84 | VStackBlock { 85 | StackingSpacer(minLength: 100) 86 | button 87 | label 88 | .viewBlock 89 | .padding(16) 90 | .padding(.vertical, 50) 91 | } 92 | } 93 | } 94 | 95 | @objc private func updateText() { 96 | label.text = BookGenerator.loremIpsum(length: [10, 50, 100].randomElement()!) 97 | requestUpdateSizing(animated: true) 98 | } 99 | 100 | } 101 | 102 | private final class ShortHeaderView: CodeBasedView, ScrollStickyContentType { 103 | 104 | private let label = UILabel() 105 | 106 | init() { 107 | 108 | super.init(frame: .null) 109 | 110 | backgroundColor = .systemPurple 111 | 112 | let label = UILabel() 113 | label.text = "Short" 114 | 115 | Mondrian.buildSubviews(on: self) { 116 | 117 | VStackBlock { 118 | StackingSpacer(minLength: 100) 119 | label 120 | .viewBlock 121 | .padding(32) 122 | } 123 | } 124 | } 125 | 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /ScrollEdgeControl-Demo/DemoHorizontalViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MondrianLayout 3 | import ScrollEdgeControl 4 | import StackScrollView 5 | import StorybookUI 6 | import UIKit 7 | 8 | final class DemoHorizontalViewController: UIViewController { 9 | 10 | struct Configuration { 11 | var numberOfElements: Int = 5 12 | var startSideConfiguration: ScrollEdgeControl.Configuration? 13 | var endSideConfiguration: ScrollEdgeControl.Configuration? 14 | } 15 | 16 | private let scrollView = StackScrollView( 17 | frame: .zero, 18 | collectionViewLayout: { 19 | let layout = UICollectionViewFlowLayout() 20 | layout.scrollDirection = .horizontal 21 | layout.minimumLineSpacing = 0 22 | layout.minimumInteritemSpacing = 0 23 | layout.sectionInset = .zero 24 | return layout 25 | }() 26 | ) 27 | 28 | let startScrollEdgeControl: ScrollEdgeControl? 29 | let endScrollEdgeControl: ScrollEdgeControl? 30 | 31 | private let menuView = StackScrollView() 32 | private let configuration: Configuration 33 | 34 | init( 35 | configuration: Configuration 36 | ) { 37 | self.configuration = configuration 38 | 39 | if let configuration = configuration.startSideConfiguration { 40 | self.startScrollEdgeControl = ScrollEdgeControl(edge: .left, configuration: configuration, activityIndicatorView: ScrollEdgeActivityIndicatorView(color: .black)) 41 | } else { 42 | self.startScrollEdgeControl = nil 43 | } 44 | 45 | if let configuration = configuration.endSideConfiguration { 46 | self.endScrollEdgeControl = ScrollEdgeControl(edge: .right, configuration: configuration, activityIndicatorView: ScrollEdgeActivityIndicatorView(color: .black)) 47 | } else { 48 | self.endScrollEdgeControl = nil 49 | } 50 | 51 | super.init(nibName: nil, bundle: nil) 52 | } 53 | 54 | required init?( 55 | coder: NSCoder 56 | ) { 57 | fatalError("init(coder:) has not been implemented") 58 | } 59 | 60 | override func viewDidLoad() { 61 | super.viewDidLoad() 62 | let scrollView = self.scrollView 63 | 64 | view.backgroundColor = .white 65 | 66 | view.mondrian.buildSubviews { 67 | LayoutContainer(attachedSafeAreaEdges: .all) { 68 | 69 | VStackBlock { 70 | scrollView 71 | .viewBlock 72 | .height(120) 73 | menuView 74 | } 75 | } 76 | } 77 | 78 | menuView.append(views: [ 79 | Components.makeScrollViewDebuggingView(scrollView: scrollView) 80 | ]) 81 | 82 | if let control = startScrollEdgeControl { 83 | scrollView.addSubview(control) 84 | 85 | menuView.append(views: [ 86 | Components.makeSelectionView( 87 | title: "Left: Activate", 88 | onTap: { 89 | control.setActivityState(.active, animated: true) 90 | } 91 | ), 92 | Components.makeSelectionView( 93 | title: "Left: Deactivate", 94 | onTap: { 95 | control.setActivityState(.inactive, animated: true) 96 | } 97 | ), 98 | ]) 99 | } 100 | 101 | if let control = endScrollEdgeControl { 102 | scrollView.addSubview(control) 103 | 104 | menuView.append(views: [ 105 | Components.makeSelectionView( 106 | title: "Right: Activate", 107 | onTap: { 108 | control.setActivityState(.active, animated: true) 109 | } 110 | ), 111 | Components.makeSelectionView( 112 | title: "Right: Deactivate", 113 | onTap: { 114 | control.setActivityState(.inactive, animated: true) 115 | } 116 | ), 117 | ]) 118 | } 119 | 120 | menuView.append(views: [ 121 | Components.makeStepperView( 122 | title: "Inset left", 123 | onIncreased: { 124 | scrollView.contentInset.left += 20 125 | }, 126 | onDecreased: { 127 | scrollView.contentInset.left -= 20 128 | } 129 | ), 130 | Components.makeStepperView( 131 | title: "Inset right", 132 | onIncreased: { 133 | scrollView.contentInset.right += 20 134 | }, 135 | onDecreased: { 136 | scrollView.contentInset.right -= 20 137 | } 138 | ), 139 | ]) 140 | 141 | let cells = (0..<(configuration.numberOfElements)).map { _ in 142 | Components.makeDemoCell() 143 | } 144 | 145 | scrollView.append(views: cells) 146 | 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScrollEdgeControl 2 | 3 | Replacement of UIRefreshControl, and more functions. 4 | 5 | ## Overview 6 | 7 | ScrollEdgeControl is a UI component that is similar to UIRefreshControl. but it pulls up to the even further abstracted component. 8 | 9 | ScrollEdgeControl can attach to every edge in a scroll view. 10 | For instance, pulling to down, up, left, right to trigger something activity such as refreshing. (pull-to-activate) 11 | 12 | It supports also disabling pull-to-activate, it would be useful in case of displaying as a loading indicator at bottom of the list. 13 | 14 | The content on this control supports display any view you want. 15 | 16 | ## Showcase 17 | 18 | **Vertical** 19 | | Top | Bottom | 20 | |---|---| 21 | | | | 22 | 23 | **Horizontal** 24 | | Left | Right | 25 | |---|---| 26 | | | | 27 | 28 | **More patterns** 29 | | pull to refresh and additional loadings | 30 | |---| 31 | | | 32 | 33 | ## Installation 34 | 35 | **Cocoapods** 36 | 37 | Including custom activity indicator 38 | ```ruby 39 | pod "ScrollEdgeControl" 40 | ``` 41 | 42 | If you need only core component 43 | ```ruby 44 | pod "ScrollEdgeControl/Core" 45 | ``` 46 | 47 | **SwiftPM** 48 | 49 | ```swift 50 | dependencies: [ 51 | .package(url: "https://github.com/muukii/ScrollEdgeControl.git", exact: "") 52 | ] 53 | ``` 54 | 55 | ## How to use 56 | 57 | **Setting up** 58 | 59 | ```swift 60 | let scrollEdgeControl = ScrollEdgeControl( 61 | edge: .top, // ✅ a target edge to add this control 62 | configuration: .init(), // ✅ customizing behavior of this control 63 | activityIndicatorView: ScrollEdgeActivityIndicatorView(color: .black) // ✅ Adding your own component to display on this control 64 | ) 65 | ``` 66 | 67 | ```swift 68 | let scrollableView: UIScrollView // ✅ could be `UIScrollView`, `UITableView`, `UICollectionView` 69 | 70 | scrollableView.addSubview(scrollEdgeControl) // ✅ Could add multiple controls for each edge 71 | ``` 72 | 73 | **Handling** 74 | 75 | ```swift 76 | scrollEdgeControl.handlers.onDidActivate = { instance in 77 | 78 | ... 79 | 80 | // after activity completed 81 | instance.setActivityState(.inactive, animated: true) 82 | } 83 | ``` 84 | 85 | ## Customizing the content 86 | 87 | ScrollEdgeControl supports to display any content you want by following protocol. 88 | Which means you can create fully customized design to display the activity. 89 | 90 | ```swift 91 | protocol ScrollEdgeActivityIndicatorViewType 92 | ``` 93 | 94 | ```swift 95 | class YourContent: ScrollEdgeActivityIndicatorViewType { 96 | 97 | func update(withState state: ScrollEdgeControl.ActivatingState) { 98 | // Needs implementation 99 | } 100 | } 101 | ``` 102 | 103 | ```swift 104 | let scrollEdgeControl: ScrollEdgeControl 105 | 106 | let yourContent: YourContent 107 | 108 | scrollEdgeControl.setActivityIndicatorView(yourContent) 109 | ``` 110 | 111 | ## Behind the scenes 112 | 113 | Creating a component such as UIRefreshControl is quite hard. 114 | Observing scroll, layout, updating content-inset. 115 | While we were inspecting UIRefreshControl, we noticed UIScrollView's content-inset return 0 when it's refreshing. but adjusted-content-inset presents actual value in displaying. 116 | (for example, content-inset-top: 0, adjusted-content-inset-top: 50) 117 | 118 | So UIRefreshControl works internally to prevent spreading side-effect by changing content-inset. 119 | We need this trick to create our own custom control for UIScrollView. 120 | 121 | In the end, we decided to get this done with method-swizzling. 122 | Swapping content-inset getter setter, managing local content-inset, and then we return the value to outside including adding, subtracting actual content-inset and local content-inset. 123 | 124 | ## Why uses Advance in dependency 125 | 126 | [Advance](https://github.com/timdonnelly/Advance) helps animation in the scroll view. 127 | 128 | It is a library to run animations with fully computable values using CADisplayLink. 129 | 130 | UIScrollView's animations are not in CoreAnimation. 131 | Those are computed in CPU every frame. that's why we can handle it in the UIScrollView delegate. 132 | We can update content offset with UIView animation, but sometimes it's going to be weird animation. 133 | To solve it, using CADisplayLink, update values for each frame. 134 | 135 | Refs: 136 | - https://medium.com/@esskeetit/how-uiscrollview-works-e418adc47060#97c7 137 | 138 | ## Author 139 | 140 | - [Muukii](https://github.com/muukii) 141 | - [takumatt](https://github.com/takumatt) 142 | 143 | ## License 144 | 145 | MIT 146 | -------------------------------------------------------------------------------- /ScrollEdgeControl/Library/DonutsIndicatorView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A view that shows that a task is in progress. 4 | public final class DonutsIndicatorView: UIView { 5 | 6 | public enum Size: CaseIterable { 7 | case medium 8 | case small 9 | 10 | var intrinsicContentSize: CGSize { 11 | switch self { 12 | case .medium: 13 | return CGSize(width: 22, height: 22) 14 | case .small: 15 | return CGSize(width: 12, height: 12) 16 | } 17 | } 18 | } 19 | 20 | public struct Color { 21 | 22 | public let placeholderColor: UIColor 23 | public let tickColor: UIColor 24 | 25 | public static let white: Color = .init( 26 | placeholderColor: UIColor(white: 1, alpha: 0.1), 27 | tickColor: UIColor(white: 1, alpha: 0.2) 28 | ) 29 | 30 | public static let black: Color = .init( 31 | placeholderColor: UIColor(white: 0, alpha: 0.1), 32 | tickColor: UIColor(white: 0, alpha: 0.2) 33 | ) 34 | 35 | public init(placeholderColor: UIColor, tickColor: UIColor) { 36 | self.placeholderColor = placeholderColor 37 | self.tickColor = tickColor 38 | } 39 | } 40 | 41 | // MARK: - Properties 42 | 43 | private let placeholderShapeLayer = CAShapeLayer() 44 | private let tickShapeLayer = CAShapeLayer() 45 | 46 | public let size: Size 47 | public let color: Color 48 | 49 | /// A boolean value that indicates the animation is running. 50 | /// And it indicates also whether the animation would be restored when re-enter hiererchy. 51 | public private(set) var isAnimating: Bool = false 52 | 53 | private var currentAnimator: UIViewPropertyAnimator? 54 | 55 | public override var intrinsicContentSize: CGSize { 56 | size.intrinsicContentSize 57 | } 58 | 59 | // MARK: - Initializers 60 | 61 | public init( 62 | size: Size, 63 | color: Color = .black 64 | ) { 65 | 66 | self.size = size 67 | self.color = color 68 | 69 | super.init(frame: .zero) 70 | 71 | let lineWidth: CGFloat 72 | 73 | switch size { 74 | case .medium: 75 | lineWidth = 3 76 | case .small: 77 | lineWidth = 2 78 | } 79 | 80 | do { 81 | layer.addSublayer(placeholderShapeLayer) 82 | 83 | placeholderShapeLayer.fillColor = UIColor.clear.cgColor 84 | placeholderShapeLayer.lineWidth = lineWidth 85 | } 86 | 87 | do { 88 | 89 | layer.addSublayer(tickShapeLayer) 90 | 91 | tickShapeLayer.fillColor = UIColor.clear.cgColor 92 | tickShapeLayer.strokeStart = 0 93 | tickShapeLayer.strokeEnd = 0.2 94 | tickShapeLayer.lineCap = .round 95 | tickShapeLayer.lineWidth = lineWidth 96 | } 97 | 98 | self.alpha = 0 99 | 100 | setColor(color) 101 | } 102 | 103 | @available(*, unavailable) 104 | public required init?(coder: NSCoder) { 105 | fatalError("init(coder:) has not been implemented") 106 | } 107 | 108 | public func setColor(_ color: Color) { 109 | tickShapeLayer.strokeColor = color.tickColor.cgColor 110 | placeholderShapeLayer.strokeColor = color.placeholderColor.cgColor 111 | } 112 | 113 | // MARK: - Functions 114 | 115 | /// Starts the animation with throttling 116 | /// 117 | /// - TODO: adds a flag to disable throttling that indicates animation appears immediately. 118 | public func startAnimating() { 119 | 120 | assert(Thread.isMainThread) 121 | 122 | guard !isAnimating else { return } 123 | 124 | isAnimating = true 125 | _startAnimating() 126 | 127 | let animator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { 128 | self.alpha = 1 129 | } 130 | 131 | currentAnimator?.stopAnimation(true) 132 | 133 | animator.startAnimation() 134 | 135 | currentAnimator = animator 136 | 137 | } 138 | 139 | /// Stops the animation with throttling 140 | /// 141 | /// - TODO: adds a flag to disable throttling that indicates animation disappears immediately. 142 | public func stopAnimating() { 143 | 144 | assert(Thread.isMainThread) 145 | 146 | guard isAnimating else { 147 | return 148 | } 149 | 150 | isAnimating = false 151 | 152 | let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { 153 | self.alpha = 0 154 | } 155 | 156 | animator.addCompletion { _ in 157 | self._stopAnimating() 158 | } 159 | 160 | currentAnimator?.stopAnimation(true) 161 | currentAnimator = animator 162 | 163 | animator.startAnimation() 164 | 165 | } 166 | 167 | private func _startAnimating() { 168 | 169 | let rotate = CABasicAnimation(keyPath: "transform.rotation.z") 170 | 171 | rotate.fromValue = 0 172 | rotate.toValue = CGFloat.pi * 2 173 | rotate.repeatCount = .infinity 174 | 175 | let group = CAAnimationGroup() 176 | group.animations = [ 177 | rotate 178 | ] 179 | group.duration = 0.4 180 | group.repeatCount = .infinity 181 | 182 | tickShapeLayer.add(group, forKey: "animation") 183 | 184 | } 185 | 186 | private func _stopAnimating() { 187 | 188 | tickShapeLayer.removeAnimation(forKey: "animation") 189 | } 190 | 191 | public override func didMoveToWindow() { 192 | super.didMoveToWindow() 193 | if window != nil, isAnimating { 194 | _startAnimating() 195 | } 196 | } 197 | 198 | public override func layoutSubviews() { 199 | super.layoutSubviews() 200 | placeholderShapeLayer.frame = self.layer.bounds 201 | tickShapeLayer.frame = self.layer.bounds 202 | let path = UIBezierPath( 203 | roundedRect: self.layer.bounds, 204 | cornerRadius: .infinity 205 | ) 206 | placeholderShapeLayer.path = path.cgPath 207 | tickShapeLayer.path = path.cgPath 208 | } 209 | 210 | } 211 | 212 | public final class DonutsIndicatorFractionView: UIView { 213 | 214 | public typealias Size = DonutsIndicatorView.Size 215 | 216 | public typealias Color = DonutsIndicatorView.Color 217 | 218 | // MARK: - Properties 219 | 220 | private let placeholderShapeLayer = CAShapeLayer() 221 | private let tickShapeLayer = CAShapeLayer() 222 | 223 | public let size: Size 224 | public let color: Color 225 | 226 | public override var intrinsicContentSize: CGSize { 227 | size.intrinsicContentSize 228 | } 229 | 230 | // MARK: - Initializers 231 | 232 | public init( 233 | size: Size, 234 | color: Color = .black 235 | ) { 236 | 237 | self.size = size 238 | self.color = color 239 | 240 | super.init(frame: .zero) 241 | 242 | let lineWidth: CGFloat 243 | 244 | switch size { 245 | case .medium: 246 | lineWidth = 3 247 | case .small: 248 | lineWidth = 2 249 | } 250 | 251 | do { 252 | layer.addSublayer(placeholderShapeLayer) 253 | 254 | placeholderShapeLayer.fillColor = UIColor.clear.cgColor 255 | placeholderShapeLayer.lineWidth = lineWidth 256 | } 257 | 258 | do { 259 | 260 | layer.addSublayer(tickShapeLayer) 261 | 262 | tickShapeLayer.fillColor = UIColor.clear.cgColor 263 | tickShapeLayer.strokeStart = 0 264 | tickShapeLayer.strokeEnd = 0 265 | tickShapeLayer.lineCap = .round 266 | tickShapeLayer.lineWidth = lineWidth 267 | } 268 | 269 | self.alpha = 0 270 | 271 | setColor(color) 272 | } 273 | 274 | public required init?(coder: NSCoder) { 275 | fatalError("init(coder:) has not been implemented") 276 | } 277 | 278 | // MARK: - Functions 279 | 280 | public func setColor(_ color: Color) { 281 | tickShapeLayer.strokeColor = color.tickColor.cgColor 282 | placeholderShapeLayer.strokeColor = color.placeholderColor.cgColor 283 | } 284 | 285 | private let progressAnimation = CASpringAnimation( 286 | keyPath: #keyPath(CAShapeLayer.strokeEnd), 287 | damping: 1, 288 | response: 0.1 289 | ) 290 | 291 | public func setProgress(_ progress: CGFloat) { 292 | 293 | tickShapeLayer.removeAnimation(forKey: "progress") 294 | 295 | progressAnimation.fromValue = tickShapeLayer.presentation()?.strokeEnd 296 | progressAnimation.toValue = max(0, min(1, progress)) 297 | progressAnimation.isAdditive = true 298 | progressAnimation.fillMode = .both 299 | progressAnimation.isRemovedOnCompletion = false 300 | 301 | tickShapeLayer.add(progressAnimation, forKey: "progress") 302 | 303 | } 304 | 305 | public override func layoutSubviews() { 306 | super.layoutSubviews() 307 | placeholderShapeLayer.frame = self.layer.bounds 308 | tickShapeLayer.frame = self.layer.bounds 309 | let path = UIBezierPath( 310 | roundedRect: self.layer.bounds, 311 | cornerRadius: .infinity 312 | ) 313 | placeholderShapeLayer.path = path.cgPath 314 | tickShapeLayer.path = path.cgPath 315 | } 316 | 317 | } 318 | 319 | extension CASpringAnimation { 320 | 321 | /** 322 | Creates an instance from damping and response. 323 | the response calucation comes from https://medium.com/@nathangitter/building-fluid-interfaces-ios-swift-9732bb934bf5 324 | */ 325 | fileprivate convenience init( 326 | keyPath path: String?, 327 | damping: CGFloat, 328 | response: CGFloat, 329 | initialVelocity: CGFloat = 0 330 | ) { 331 | let stiffness = pow(2 * .pi / response, 2) 332 | let damp = 4 * .pi * damping / response 333 | 334 | self.init(keyPath: path) 335 | self.mass = 1 336 | self.stiffness = stiffness 337 | self.damping = damp 338 | self.initialVelocity = initialVelocity 339 | 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /ScrollEdgeControl/Core/ScrollStickyVerticalHeaderView.swift: -------------------------------------------------------------------------------- 1 | import Advance 2 | import UIKit 3 | 4 | public protocol ScrollStickyContentType: UIView { 5 | 6 | func receive( 7 | state: ScrollStickyVerticalHeaderView.ContentState, 8 | oldState: ScrollStickyVerticalHeaderView.ContentState? 9 | ) 10 | } 11 | 12 | extension ScrollStickyContentType { 13 | 14 | public func requestUpdateSizing(animated: Bool) { 15 | guard let target = superview as? ScrollStickyVerticalHeaderView else { 16 | return 17 | } 18 | 19 | target.reloadState(animated: animated) 20 | } 21 | 22 | public func receive( 23 | state: ScrollStickyVerticalHeaderView.ContentState, 24 | oldState: ScrollStickyVerticalHeaderView.ContentState? 25 | ) { 26 | 27 | } 28 | } 29 | 30 | /// With: ``ScrollStickyContentType`` 31 | public final class ScrollStickyVerticalHeaderView: UIView { 32 | 33 | public struct Configuration: Equatable { 34 | 35 | public var scrollsTogether: Bool 36 | public var attachesToSafeArea: Bool 37 | 38 | public init(scrollsTogether: Bool = true, attachesToSafeArea: Bool = false) { 39 | self.scrollsTogether = scrollsTogether 40 | self.attachesToSafeArea = attachesToSafeArea 41 | } 42 | } 43 | 44 | public struct ContentState: Equatable { 45 | public var contentOffset: CGPoint = .zero 46 | public var isActive: Bool = true 47 | } 48 | 49 | struct ComponentState: Equatable { 50 | var hasAttachedToScrollView = false 51 | var safeAreaInsets: UIEdgeInsets = .zero 52 | var contentOffset: CGPoint = .zero 53 | var isActive: Bool = true 54 | 55 | var configuration: Configuration 56 | } 57 | 58 | public var configuration: Configuration { 59 | get { componentState.configuration } 60 | set { componentState.configuration = newValue } 61 | } 62 | 63 | public var isActive: Bool { 64 | componentState.isActive 65 | } 66 | 67 | private var isInAnimating = false 68 | 69 | private var componentState: ComponentState { 70 | didSet { 71 | guard oldValue != componentState else { 72 | return 73 | } 74 | update(with: componentState, oldState: oldValue) 75 | } 76 | } 77 | 78 | private var contentState: ContentState = .init() { 79 | didSet { 80 | guard oldValue != contentState else { 81 | return 82 | } 83 | contentView?.receive(state: contentState, oldState: oldValue) 84 | } 85 | } 86 | 87 | private var contentView: ScrollStickyContentType? 88 | 89 | private var observations: [NSKeyValueObservation] = [] 90 | 91 | private var contentInsetTopDynamicAnimator: Animator? 92 | 93 | private var topConstraint: NSLayoutConstraint? 94 | 95 | private weak var targetScrollView: UIScrollView? = nil 96 | 97 | public init(configuration: Configuration = .init()) { 98 | 99 | self.componentState = .init(configuration: configuration) 100 | super.init(frame: .null) 101 | } 102 | 103 | @available(*, unavailable) 104 | public required init?(coder: NSCoder) { 105 | fatalError("init(coder:) has not been implemented") 106 | } 107 | 108 | public func setContent(_ contentView: ScrollStickyContentType) { 109 | 110 | self.contentView = contentView 111 | 112 | subviews.forEach { 113 | $0.removeFromSuperview() 114 | } 115 | 116 | addSubview(contentView) 117 | reloadState(animated: false) 118 | 119 | contentView.receive(state: contentState, oldState: nil) 120 | } 121 | 122 | public override func layoutSubviews() { 123 | super.layoutSubviews() 124 | 125 | // sync frame 126 | // we don't use AutoLayout to prevent the content view from expanding inside. 127 | contentView?.frame = bounds 128 | } 129 | 130 | public func setIsActive(_ isActive: Bool, animated: Bool) { 131 | 132 | if animated { 133 | isInAnimating = true 134 | componentState.isActive = isActive 135 | isInAnimating = false 136 | } else { 137 | componentState.isActive = isActive 138 | } 139 | 140 | } 141 | 142 | public override func didMoveToSuperview() { 143 | 144 | super.didMoveToSuperview() 145 | 146 | // We cannot rely on the existence of self.superview to decide whether to setup scrollView, 147 | // because it did not exist yet on iOS 13. 148 | guard componentState.hasAttachedToScrollView == false else { 149 | // No need to setup scrollView. 150 | return 151 | } 152 | 153 | guard let superview = superview else { 154 | componentState.hasAttachedToScrollView = false 155 | return 156 | } 157 | 158 | guard let scrollView = superview as? UIScrollView else { 159 | assertionFailure() 160 | return 161 | } 162 | 163 | setupInScrollView(targetScrollView: scrollView) 164 | } 165 | 166 | private func setupInScrollView(targetScrollView scrollView: UIScrollView) { 167 | 168 | guard componentState.hasAttachedToScrollView == false else { 169 | assertionFailure("\(self) is alread atttached to scrollView\(targetScrollView as Any)") 170 | return 171 | } 172 | 173 | self.targetScrollView = scrollView 174 | 175 | componentState.hasAttachedToScrollView = true 176 | 177 | addObservation(scrollView: scrollView) 178 | 179 | self.translatesAutoresizingMaskIntoConstraints = false 180 | 181 | NSLayoutConstraint.activate([ 182 | leftAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leftAnchor), 183 | rightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.rightAnchor), 184 | bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.contentLayoutGuide.topAnchor), 185 | ]) 186 | 187 | // creates topConstraint 188 | reloadState(animated: false) 189 | } 190 | 191 | private func addObservation(scrollView: UIScrollView) { 192 | 193 | self.observations.forEach { 194 | $0.invalidate() 195 | } 196 | 197 | let newObservations: [NSKeyValueObservation] 198 | 199 | newObservations = [ 200 | scrollView.observe(\.safeAreaInsets, options: [.initial, .new]) { 201 | [weak self] scrollView, _ in 202 | 203 | self?.componentState.safeAreaInsets = scrollView.safeAreaInsets 204 | }, 205 | scrollView.observe(\.contentOffset, options: [.initial, .new]) { 206 | [weak self] scrollView, value in 207 | 208 | guard 209 | let self = self 210 | else { 211 | return 212 | } 213 | 214 | self.componentState.contentOffset = scrollView.contentOffset 215 | self.contentState.contentOffset = scrollView.contentOffset 216 | }, 217 | scrollView.layer.observe(\.sublayers, options: [.new]) { 218 | [weak self, weak scrollView] layer, value in 219 | 220 | guard 221 | let self = self, 222 | let scrollView = scrollView 223 | else { 224 | return 225 | } 226 | 227 | // check if already inserted to index 0. 228 | if let firstSubview = scrollView.subviews.first, 229 | firstSubview == self { 230 | return 231 | } 232 | 233 | scrollView.insertSubview(self, at: 0) 234 | }, 235 | 236 | ] 237 | 238 | self.observations = newObservations 239 | } 240 | 241 | internal func reloadState(animated: Bool) { 242 | 243 | if animated { 244 | isInAnimating = true 245 | update(with: componentState, oldState: nil) 246 | isInAnimating = false 247 | } else { 248 | update(with: componentState, oldState: nil) 249 | } 250 | } 251 | 252 | private func update(with state: ComponentState, oldState: ComponentState?) { 253 | 254 | assert(Thread.isMainThread) 255 | 256 | let animated = isInAnimating 257 | 258 | if state.isActive != oldState?.isActive { 259 | contentState.isActive = state.isActive 260 | } 261 | 262 | if let targetScrollView = targetScrollView, state.configuration.attachesToSafeArea != oldState?.configuration.attachesToSafeArea { 263 | 264 | if state.configuration.attachesToSafeArea { 265 | topConstraint?.isActive = false 266 | topConstraint = topAnchor.constraint(equalTo: targetScrollView.safeAreaLayoutGuide.topAnchor) 267 | topConstraint?.isActive = true 268 | } else { 269 | topConstraint?.isActive = false 270 | topConstraint = topAnchor.constraint(equalTo: targetScrollView.frameLayoutGuide.topAnchor) 271 | topConstraint?.isActive = true 272 | } 273 | 274 | layoutIfNeeded() 275 | 276 | } 277 | 278 | if let targetScrollView = targetScrollView, 279 | let contentView = contentView, 280 | state.safeAreaInsets != oldState?.safeAreaInsets || state.isActive != oldState?.isActive || state.configuration != oldState?.configuration 281 | { 282 | 283 | let targetValue: CGFloat = { 284 | if state.isActive { 285 | 286 | let size = calculateFittingSize(view: contentView) 287 | 288 | if state.configuration.attachesToSafeArea { 289 | let targetValue = size.height 290 | return targetValue 291 | } else { 292 | let targetValue = size.height - state.safeAreaInsets.top 293 | return targetValue 294 | } 295 | 296 | } else { 297 | 298 | return 0 299 | } 300 | }() 301 | 302 | if animated { 303 | 304 | contentInsetTopDynamicAnimator = Animator(initialValue: targetScrollView.contentInset.top) 305 | 306 | contentInsetTopDynamicAnimator!.onChange = { [weak self, weak targetScrollView] value in 307 | 308 | guard let self = self, let targetScrollView = targetScrollView else { 309 | return 310 | } 311 | 312 | guard targetScrollView.isTracking == false else { 313 | self.contentInsetTopDynamicAnimator?.cancelRunningAnimation() 314 | self.contentInsetTopDynamicAnimator = nil 315 | targetScrollView.contentInset.top = targetValue 316 | return 317 | } 318 | 319 | targetScrollView.contentInset.top = value 320 | 321 | } 322 | 323 | contentInsetTopDynamicAnimator!.simulate( 324 | using: SpringFunction( 325 | target: targetValue, 326 | tension: 1200, 327 | damping: 120 328 | ) 329 | ) 330 | 331 | } else { 332 | 333 | contentInsetTopDynamicAnimator?.cancelRunningAnimation() 334 | contentInsetTopDynamicAnimator = nil 335 | 336 | targetScrollView.contentInset.top = targetValue 337 | 338 | } 339 | } 340 | 341 | if let topConstraint = topConstraint, state.contentOffset != oldState?.contentOffset || state.configuration != oldState?.configuration { 342 | if self.configuration.scrollsTogether { 343 | topConstraint.constant = min(0, -(state.contentOffset.y + (targetScrollView?.adjustedContentInset.top ?? 0))) 344 | } 345 | 346 | } 347 | 348 | } 349 | 350 | private func calculateFittingSize(view: UIView) -> CGSize { 351 | 352 | let size = view.systemLayoutSizeFitting( 353 | .init(width: self.bounds.width, height: UIView.layoutFittingCompressedSize.height), 354 | withHorizontalFittingPriority: .required, 355 | verticalFittingPriority: .fittingSizeLevel 356 | ) 357 | 358 | return size 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /ScrollEdgeControl/Core/ScrollEdgeControl.swift: -------------------------------------------------------------------------------- 1 | 2 | import Advance 3 | import UIKit 4 | import os.log 5 | 6 | /// A protocol that indicates the view can be displayed on ScrollEdgeControl. 7 | public protocol ScrollEdgeActivityIndicatorViewType: UIView { 8 | 9 | func update(withState state: ScrollEdgeControl.ActivatingState) 10 | } 11 | 12 | /// A customizable control that attaches at edges of the scrollview. 13 | /// 14 | /// - Pulling to refresh (interchangeable with UIRefreshControl) 15 | /// - Can be attached in multiple edges (top, left, right, bottom) 16 | public final class ScrollEdgeControl: UIControl { 17 | 18 | public struct Handlers { 19 | public var onDidActivate: (ScrollEdgeControl) -> Void = { _ in } 20 | } 21 | 22 | /** 23 | Configurations for ScrollEdgeControl 24 | */ 25 | public struct Configuration { 26 | 27 | /** 28 | Options how lays out ScrollEdgeControl in ScrollView 29 | */ 30 | public enum LayoutMode { 31 | /** 32 | Fixes the position to the specified edge with respecting content-inset. 33 | */ 34 | case fixesToEdge 35 | 36 | /** 37 | Scrolls itself according to the content 38 | */ 39 | case scrollingAlongContent 40 | } 41 | 42 | public enum ZLayoutMode { 43 | /// front of content 44 | case front 45 | /// back of content 46 | case back 47 | } 48 | 49 | public enum PullToActivateMode { 50 | case enabled(addsInset: Bool) 51 | case disabled 52 | } 53 | 54 | public var layoutMode: LayoutMode = .fixesToEdge 55 | public var zLayoutMode: ZLayoutMode = .back 56 | 57 | public var pullToActivateMode: PullToActivateMode = .enabled(addsInset: true) 58 | 59 | /// A length to the target edge. 60 | /// You might use this when you need to lay it out away from the edge. 61 | public var marginToEdge: CGFloat = 0 62 | 63 | public init() {} 64 | 65 | public init( 66 | _ modify: (inout Self) -> Void 67 | ) { 68 | var instance = Self.init() 69 | modify(&instance) 70 | self = instance 71 | } 72 | 73 | } 74 | 75 | public enum ActivatingState: Equatable { 76 | case triggering(progress: CGFloat) 77 | case active 78 | case completed 79 | } 80 | 81 | /** 82 | A representation of the edge 83 | */ 84 | public enum Edge: Equatable { 85 | case top 86 | case bottom 87 | case right 88 | case left 89 | 90 | enum Direction: Equatable { 91 | case vertical 92 | case horizontal 93 | } 94 | 95 | var direction: Direction { 96 | switch self { 97 | case .top, .bottom: return .vertical 98 | case .left, .right: return .horizontal 99 | } 100 | } 101 | } 102 | 103 | private enum DirectionalEdge { 104 | case start 105 | case end 106 | } 107 | 108 | public struct ComponentState: Equatable { 109 | var hasAttachedToScrollView = false 110 | var isIdlingToPull: Bool = true 111 | public fileprivate(set) var activityState: ActivityState = .inactive 112 | } 113 | 114 | public struct ActivityState: Equatable { 115 | public var isActive: Bool 116 | 117 | /** 118 | A Boolean value that indicates whether adding content inset to the target scroll view to make activity indicator visible. 119 | */ 120 | public var addsInset: Bool 121 | 122 | public init( 123 | isActive: Bool, 124 | addsInset: Bool 125 | ) { 126 | self.isActive = isActive 127 | self.addsInset = addsInset 128 | } 129 | 130 | public static var active: Self { 131 | return .init(isActive: true, addsInset: true) 132 | } 133 | 134 | public static var inactive: Self { 135 | return .init(isActive: false, addsInset: false) 136 | } 137 | 138 | public static func active(addsInset: Bool) -> Self { 139 | return .init(isActive: true, addsInset: addsInset) 140 | } 141 | 142 | public static func inactive(addsInset: Bool) -> Self { 143 | return .init(isActive: false, addsInset: addsInset) 144 | } 145 | 146 | } 147 | 148 | fileprivate enum Log { 149 | 150 | private static let log: OSLog = { 151 | #if SCROLLEDGECONTROL_LOG_ENABLED 152 | return OSLog.init(subsystem: "ScrollEdgeControl", category: "ScrollEdgeControl") 153 | #else 154 | return .disabled 155 | #endif 156 | }() 157 | 158 | static func debug(_ object: Any...) { 159 | os_log(.debug, log: log, "%@", object.map { "\($0)" }.joined(separator: " ")) 160 | } 161 | 162 | } 163 | 164 | private enum Constants { 165 | 166 | static let refreshIndicatorLengthAlongScrollDirection: CGFloat = 50 167 | static let rubberBandingLengthToTriggerRefreshing: CGFloat = 168 | refreshIndicatorLengthAlongScrollDirection * 1.6 169 | static let refreshAnimationAutoScrollMargin: CGFloat = 10 170 | } 171 | 172 | public var handlers: Handlers = .init() 173 | 174 | private let targetEdge: Edge 175 | 176 | public var componentState: ComponentState = .init() 177 | 178 | public var configuration: Configuration { 179 | didSet { 180 | layoutSelfInScrollView() 181 | } 182 | } 183 | 184 | public var isActive: Bool { 185 | componentState.activityState.isActive 186 | } 187 | 188 | private weak var targetScrollView: UIScrollView? = nil 189 | private var contentInsetDynamicAnimator: Advance.Animator? 190 | private var activityIndicatorView: ScrollEdgeActivityIndicatorViewType? 191 | private var offsetObservation: NSKeyValueObservation? 192 | private var insetObservation: NSKeyValueObservation? 193 | private let feedbackGenerator: UIImpactFeedbackGenerator = .init(style: .light) 194 | private var scrollController: ScrollController? 195 | 196 | private var refreshingState: ActivatingState = .completed { 197 | didSet { 198 | activityIndicatorView?.update(withState: refreshingState) 199 | } 200 | } 201 | 202 | public init( 203 | edge: Edge, 204 | configuration: Configuration, 205 | activityIndicatorView: ScrollEdgeActivityIndicatorViewType 206 | ) { 207 | 208 | self.targetEdge = edge 209 | self.configuration = configuration 210 | 211 | super.init(frame: .zero) 212 | 213 | isUserInteractionEnabled = false 214 | 215 | setActivityIndicatorView(activityIndicatorView) 216 | } 217 | 218 | @available(*, unavailable) 219 | public required init?( 220 | coder: NSCoder 221 | ) { 222 | fatalError("init(coder:) has not been implemented") 223 | } 224 | 225 | deinit { 226 | 227 | } 228 | 229 | /** 230 | Sets Activity State - animatable 231 | */ 232 | public func setActivityState(_ state: ActivityState, animated: Bool) { 233 | 234 | let previous = componentState.activityState 235 | componentState.activityState = state 236 | 237 | switch state.isActive { 238 | case true: 239 | 240 | guard !previous.isActive else { 241 | break 242 | } 243 | 244 | refreshingState = .active 245 | 246 | case false: 247 | 248 | guard previous.isActive == true else { 249 | break 250 | } 251 | 252 | refreshingState = .completed 253 | 254 | } 255 | 256 | switch state.addsInset { 257 | case true: 258 | addLocalContentInsetInTargetScrollView(animated: animated) 259 | case false: 260 | removeLocalContentInsetInTargetScrollView(animated: animated) 261 | } 262 | } 263 | 264 | 265 | public func setActivityIndicatorView(_ view: ScrollEdgeActivityIndicatorViewType) { 266 | 267 | if let current = activityIndicatorView { 268 | current.removeFromSuperview() 269 | } 270 | 271 | activityIndicatorView = view 272 | 273 | addSubview(view) 274 | view.frame = bounds 275 | view.autoresizingMask = [.flexibleHeight, .flexibleWidth] 276 | 277 | layoutSelfInScrollView() 278 | 279 | activityIndicatorView?.update(withState: refreshingState) 280 | 281 | } 282 | 283 | public override func didMoveToSuperview() { 284 | 285 | super.didMoveToSuperview() 286 | 287 | guard let superview = superview else { 288 | componentState.hasAttachedToScrollView = false 289 | return 290 | } 291 | 292 | guard let scrollView = superview as? UIScrollView else { 293 | assertionFailure() 294 | return 295 | } 296 | 297 | _ = UIScrollView.swizzle 298 | 299 | scrollView._userContentInset = scrollView.__original_contentInset 300 | 301 | feedbackGenerator.prepare() 302 | 303 | setupInScrollView(targetScrollView: scrollView) 304 | } 305 | 306 | private func setupInScrollView(targetScrollView scrollView: UIScrollView) { 307 | 308 | guard componentState.hasAttachedToScrollView == false else { 309 | assertionFailure("\(self) is alread atttached to scrollView\(targetScrollView as Any)") 310 | return 311 | } 312 | 313 | self.targetScrollView = scrollView 314 | self.scrollController = .init(scrollView: scrollView) 315 | 316 | componentState.hasAttachedToScrollView = true 317 | 318 | addOffsetObservation(scrollView: scrollView) 319 | addInsetObservation(scrollView: scrollView) 320 | 321 | layoutSelfInScrollView() 322 | 323 | setActivityState(componentState.activityState, animated: false) 324 | } 325 | 326 | private func addLocalContentInsetInTargetScrollView(animated: Bool) { 327 | 328 | guard let scrollView = self.targetScrollView else { return } 329 | 330 | let targetHeight = Constants.refreshIndicatorLengthAlongScrollDirection 331 | 332 | guard scrollView._scrollEdgeControl_localContentInset[edge: targetEdge] != targetHeight else { 333 | return 334 | } 335 | 336 | Log.debug("add local content inset") 337 | 338 | contentInsetDynamicAnimator?.cancelRunningAnimation() 339 | 340 | if animated { 341 | 342 | contentInsetDynamicAnimator = Animator( 343 | initialValue: scrollView._scrollEdgeControl_localContentInset[edge: self.targetEdge] 344 | ) 345 | 346 | contentInsetDynamicAnimator!.onChange = { [weak self, weak scrollView] value in 347 | 348 | guard 349 | let self = self, 350 | let scrollView = scrollView 351 | else { 352 | return 353 | } 354 | 355 | let userContentInset = scrollView._userContentInset 356 | 357 | /// reads every frame to support multiple components. 358 | let capturedAdjustedContentInset = scrollView.adjustedContentInset.subtracting( 359 | scrollView._scrollEdgeControl_localContentInset 360 | ) 361 | 362 | guard scrollView.isTracking == false else { 363 | 364 | /** 365 | stop animation by interruption 366 | */ 367 | 368 | self.contentInsetDynamicAnimator?.cancelRunningAnimation() 369 | 370 | scrollView._scrollEdgeControl_localContentInset[edge: self.targetEdge] = targetHeight 371 | scrollView.__original_contentInset[edge: self.targetEdge] = 372 | userContentInset[edge: self.targetEdge] + targetHeight 373 | 374 | return 375 | } 376 | 377 | scrollView._scrollEdgeControl_localContentInset[edge: self.targetEdge] = value 378 | scrollView.__original_contentInset[edge: self.targetEdge] = 379 | userContentInset[edge: self.targetEdge] + value 380 | 381 | switch self.targetEdge { 382 | case .top: 383 | if scrollView.contentOffset.y < -targetHeight + Constants.refreshAnimationAutoScrollMargin 384 | { 385 | scrollView.contentOffset.y = -(capturedAdjustedContentInset.top + value) 386 | } 387 | case .bottom: 388 | 389 | if Self.isScrollableVertically(scrollView: scrollView), 390 | scrollView.contentOffset.y 391 | > (Self.maximumContentOffset(of: scrollView).y 392 | - Constants.refreshAnimationAutoScrollMargin) 393 | { 394 | scrollView.contentOffset.y = (Self.maximumContentOffset(of: scrollView).y + value) 395 | } 396 | case .left: 397 | if scrollView.contentOffset.x < -targetHeight + Constants.refreshAnimationAutoScrollMargin 398 | { 399 | scrollView.contentOffset.x = -(capturedAdjustedContentInset.left + value) 400 | } 401 | case .right: 402 | if Self.isScrollableHorizontally(scrollView: scrollView), 403 | scrollView.contentOffset.x 404 | > (Self.maximumContentOffset(of: scrollView).x 405 | - Constants.refreshAnimationAutoScrollMargin) 406 | { 407 | scrollView.contentOffset.x = (Self.maximumContentOffset(of: scrollView).x + value) 408 | } 409 | } 410 | } 411 | 412 | contentInsetDynamicAnimator!.simulate( 413 | using: SpringFunction( 414 | target: Constants.refreshIndicatorLengthAlongScrollDirection, 415 | tension: 1200, 416 | damping: 120 417 | ) 418 | ) 419 | 420 | } else { 421 | 422 | scrollView._scrollEdgeControl_localContentInset[edge: targetEdge] = targetHeight 423 | scrollView.__original_contentInset[edge: targetEdge] += 424 | scrollView._userContentInset[edge: targetEdge] + targetHeight 425 | 426 | } 427 | } 428 | 429 | private func removeLocalContentInsetInTargetScrollView(animated: Bool) { 430 | 431 | guard let scrollView = self.targetScrollView else { return } 432 | 433 | guard scrollView._scrollEdgeControl_localContentInset[edge: targetEdge] != 0 else { 434 | return 435 | } 436 | 437 | Log.debug("Start : remove local content inset") 438 | 439 | contentInsetDynamicAnimator?.cancelRunningAnimation() 440 | 441 | if animated { 442 | 443 | contentInsetDynamicAnimator = Animator( 444 | initialValue: Constants.refreshIndicatorLengthAlongScrollDirection 445 | ) 446 | 447 | let targetValue: CGFloat = 0 448 | 449 | contentInsetDynamicAnimator!.onChange = { [weak self, weak scrollView] value in 450 | 451 | guard let self = self else { return } 452 | guard let scrollView = scrollView else { return } 453 | 454 | if value == targetValue { 455 | Log.debug("Complete : remove local content inset") 456 | } 457 | 458 | let userContentInset = scrollView._userContentInset 459 | 460 | guard scrollView.isTracking == false else { 461 | 462 | self.contentInsetDynamicAnimator?.cancelRunningAnimation() 463 | 464 | let contentOffset = scrollView.contentOffset 465 | 466 | self.removeOffsetObservation() 467 | 468 | do { 469 | scrollView._scrollEdgeControl_localContentInset[edge: self.targetEdge] = 0 470 | scrollView.__original_contentInset[edge: self.targetEdge] = 471 | userContentInset[edge: self.targetEdge] 472 | 473 | /// to keep current tracking content offset. 474 | scrollView.contentOffset = contentOffset 475 | } 476 | 477 | self.addOffsetObservation(scrollView: scrollView) 478 | 479 | return 480 | } 481 | 482 | scrollView._scrollEdgeControl_localContentInset[edge: self.targetEdge] = value 483 | scrollView.__original_contentInset[edge: self.targetEdge] = 484 | userContentInset[edge: self.targetEdge] + (value) 485 | 486 | } 487 | 488 | contentInsetDynamicAnimator!.simulate( 489 | using: SpringFunction( 490 | target: targetValue, 491 | tension: 1200, 492 | damping: 120 493 | ) 494 | ) 495 | } else { 496 | 497 | scrollView._scrollEdgeControl_localContentInset[edge: targetEdge] = 0 498 | scrollView.__original_contentInset[edge: targetEdge] = 499 | scrollView._userContentInset[edge: targetEdge] 500 | 501 | } 502 | } 503 | 504 | private func _pullToRefresh_beginRefreshing() { 505 | 506 | guard componentState.activityState.isActive == false, 507 | let scrollView = targetScrollView 508 | else { 509 | return 510 | } 511 | 512 | componentState.activityState.isActive = true 513 | componentState.isIdlingToPull = false 514 | 515 | activityIndicatorView?.update(withState: .active) 516 | 517 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in 518 | guard let self = self else { return } 519 | self.sendActions(for: .valueChanged) 520 | self.handlers.onDidActivate(self) 521 | } 522 | 523 | feedbackGenerator.impactOccurred() 524 | 525 | guard case .enabled(true) = configuration.pullToActivateMode else { 526 | return 527 | } 528 | 529 | removeOffsetObservation() 530 | defer { 531 | addOffsetObservation(scrollView: scrollView) 532 | } 533 | 534 | scrollView._scrollEdgeControl_localContentInset[edge: targetEdge] = 535 | Constants.refreshIndicatorLengthAlongScrollDirection 536 | 537 | /// update contentInset internally with scroll locking 538 | do { 539 | /// prevents glitches 540 | scrollController!.lockScrolling() 541 | scrollView.__original_contentInset[edge: targetEdge] += 542 | Constants.refreshIndicatorLengthAlongScrollDirection 543 | scrollController!.unlockScrolling() 544 | } 545 | 546 | /// prevents jumping (still jumping slightly) 547 | var translation = scrollView.panGestureRecognizer.translation(in: nil) 548 | switch targetEdge { 549 | case .top: 550 | translation.y -= Constants.refreshIndicatorLengthAlongScrollDirection + 10 551 | case .bottom: 552 | translation.y += Constants.refreshIndicatorLengthAlongScrollDirection + 10 553 | case .left: 554 | translation.x -= Constants.refreshIndicatorLengthAlongScrollDirection + 45 555 | case .right: 556 | translation.x += Constants.refreshIndicatorLengthAlongScrollDirection + 45 557 | } 558 | /** this constant is needed to keep the current content offset */ 559 | scrollView.panGestureRecognizer.setTranslation(translation, in: nil) 560 | 561 | } 562 | 563 | private func layoutSelfInScrollView() { 564 | 565 | func setSize(_ size: CGSize) { 566 | guard self.bounds.size != size else { 567 | return 568 | } 569 | self.bounds.size = size 570 | } 571 | 572 | func setPosition(point: CGPoint) { 573 | 574 | self.resetCenter() 575 | 576 | self.layer.transform = CATransform3DMakeAffineTransform(.init(translationX: point.x, y: point.y)) 577 | 578 | } 579 | 580 | func setFrame(_ frame: CGRect, scrollView: UIScrollView) { 581 | 582 | guard scrollView.bounds.contains(frame) else { 583 | self.layer.isHidden = true 584 | return 585 | } 586 | 587 | self.layer.isHidden = false 588 | 589 | setSize(frame.size) 590 | setPosition(point: frame.origin) 591 | } 592 | 593 | func setZPosition(_ position: CGFloat) { 594 | guard layer.zPosition != position else { return } 595 | layer.zPosition = position 596 | } 597 | 598 | guard let scrollView = targetScrollView else { 599 | return 600 | } 601 | 602 | switch configuration.zLayoutMode { 603 | case .front: 604 | setZPosition(1) 605 | case .back: 606 | setZPosition(-1) 607 | } 608 | 609 | let length: CGFloat 610 | 611 | switch configuration.layoutMode { 612 | case .fixesToEdge: 613 | length = scrollView.distance(from: targetEdge) - configuration.marginToEdge 614 | case .scrollingAlongContent: 615 | length = -configuration.marginToEdge 616 | } 617 | 618 | let sizeForVertical = CGSize.init( 619 | width: scrollView.bounds.width, 620 | height: Constants.refreshIndicatorLengthAlongScrollDirection 621 | ) 622 | 623 | let sizeForHorizontal = CGSize.init( 624 | width: Constants.refreshIndicatorLengthAlongScrollDirection, 625 | height: scrollView.bounds.height 626 | ) 627 | 628 | switch targetEdge { 629 | case .top: 630 | 631 | let frame = CGRect( 632 | origin: .init( 633 | x: 0, 634 | y: -scrollView._scrollEdgeControl_localContentInset.top - length 635 | ), 636 | size: sizeForVertical 637 | ) 638 | 639 | setFrame(frame, scrollView: scrollView) 640 | 641 | case .bottom: 642 | 643 | let frame = CGRect( 644 | origin: .init( 645 | x: 0, 646 | y: scrollView.contentSize.height - Constants.refreshIndicatorLengthAlongScrollDirection 647 | + length + scrollView._scrollEdgeControl_localContentInset.bottom 648 | ), 649 | size: sizeForVertical 650 | ) 651 | 652 | setFrame(frame, scrollView: scrollView) 653 | 654 | case .left: 655 | 656 | let frame = CGRect( 657 | origin: .init( 658 | x: -scrollView._scrollEdgeControl_localContentInset.left - length, 659 | y: 0 660 | ), 661 | size: sizeForHorizontal 662 | ) 663 | 664 | setFrame(frame, scrollView: scrollView) 665 | 666 | case .right: 667 | 668 | let frame = CGRect( 669 | origin: .init( 670 | x: scrollView.contentSize.width - Constants.refreshIndicatorLengthAlongScrollDirection 671 | + length + scrollView._scrollEdgeControl_localContentInset.right, 672 | y: 0 673 | ), 674 | size: sizeForHorizontal 675 | ) 676 | 677 | setFrame(frame, scrollView: scrollView) 678 | 679 | } 680 | 681 | } 682 | 683 | private func addInsetObservation(scrollView: UIScrollView) { 684 | 685 | insetObservation = scrollView.observe(\.contentInset, options: [.old, .new]) { 686 | [weak self] scrollView, _ in 687 | 688 | guard let self = self else { return } 689 | self.layoutSelfInScrollView() 690 | 691 | } 692 | } 693 | 694 | /// https://github.com/gontovnik/DGElasticPullToRefresh/blob/master/DGElasticPullToRefresh/DGElasticPullToRefreshView.swift 695 | private func addOffsetObservation(scrollView: UIScrollView) { 696 | 697 | offsetObservation = scrollView.observe(\.contentOffset, options: [.new]) { 698 | [weak self, targetEdge] scrollView, value in 699 | 700 | guard 701 | let self = self 702 | else { 703 | return 704 | } 705 | 706 | self.layoutSelfInScrollView() 707 | 708 | if scrollView.rubberBandingLength(from: targetEdge) == 0 { 709 | self.componentState.isIdlingToPull = true 710 | } 711 | 712 | if case .enabled = self.configuration.pullToActivateMode { 713 | 714 | let remainsTriggering: Bool = { 715 | if case .triggering(let p) = self.refreshingState { 716 | return p > 0 717 | } else { 718 | return false 719 | } 720 | }() 721 | 722 | if self.componentState.isIdlingToPull && (scrollView.isTracking || remainsTriggering) { 723 | 724 | if self.componentState.activityState.isActive == false { 725 | 726 | let distanceFromEdge = scrollView.rubberBandingLength(from: targetEdge) 727 | let progressToTriggerRefreshing = max( 728 | 0, 729 | min(1, distanceFromEdge / Constants.rubberBandingLengthToTriggerRefreshing) 730 | ) 731 | 732 | let nextState = ActivatingState.triggering(progress: progressToTriggerRefreshing) 733 | 734 | if self.refreshingState != nextState { 735 | self.refreshingState = nextState 736 | } 737 | 738 | if progressToTriggerRefreshing == 1 { 739 | self._pullToRefresh_beginRefreshing() 740 | } 741 | 742 | } 743 | } 744 | } 745 | 746 | } 747 | } 748 | 749 | private func removeOffsetObservation() { 750 | offsetObservation?.invalidate() 751 | offsetObservation = nil 752 | } 753 | 754 | // MARK: - Utility 755 | 756 | private static func maximumContentOffset(of scrollView: UIScrollView) -> CGPoint { 757 | 758 | let contentInset = scrollView.adjustedContentInset.subtracting( 759 | scrollView._scrollEdgeControl_localContentInset 760 | ) 761 | 762 | return .init( 763 | x: scrollView.contentSize.width - scrollView.bounds.width + contentInset.right, 764 | y: scrollView.contentSize.height - scrollView.bounds.height + contentInset.bottom 765 | ) 766 | } 767 | 768 | private static func isScrollableVertically(scrollView: UIScrollView) -> Bool { 769 | 770 | let contentInset = scrollView._userContentInset 771 | return 772 | (scrollView.bounds.height - scrollView.contentSize.height - contentInset.top 773 | - contentInset.bottom) < 0 774 | 775 | } 776 | 777 | private static func isScrollableHorizontally(scrollView: UIScrollView) -> Bool { 778 | 779 | let contentInset = scrollView._userContentInset 780 | return 781 | (scrollView.bounds.width - scrollView.contentSize.width - contentInset.left 782 | - contentInset.right) < 0 783 | 784 | } 785 | 786 | } 787 | 788 | extension UIScrollView { 789 | 790 | fileprivate func distance(from edge: ScrollEdgeControl.Edge) -> CGFloat { 791 | 792 | switch edge { 793 | case .top: 794 | return -(contentOffset.y + adjustedContentInset.top) 795 | case .bottom: 796 | let contentOffsetMaxY = (bounds.height + contentOffset.y) 797 | return -(contentSize.height - contentOffsetMaxY + adjustedContentInset.bottom) 798 | case .left: 799 | return -(contentOffset.x + adjustedContentInset.left) 800 | case .right: 801 | let contentOffsetMaxX = (bounds.width + contentOffset.x) 802 | return -(contentSize.width - contentOffsetMaxX + adjustedContentInset.right) 803 | } 804 | 805 | } 806 | 807 | fileprivate func rubberBandingLength(from edge: ScrollEdgeControl.Edge) -> CGFloat { 808 | 809 | switch edge { 810 | case .top, .left: 811 | return distance(from: edge) 812 | case .bottom: 813 | let margin = max( 814 | 0, 815 | bounds.height - contentSize.height - adjustedContentInset.top - adjustedContentInset.bottom 816 | ) 817 | let contentOffsetMaxY = (bounds.height + contentOffset.y - margin) 818 | let value = -(contentSize.height - contentOffsetMaxY + adjustedContentInset.bottom) 819 | return value 820 | case .right: 821 | let margin = max( 822 | 0, 823 | bounds.width - contentSize.width - adjustedContentInset.left - adjustedContentInset.right 824 | ) 825 | let contentOffsetMaxX = (bounds.width + contentOffset.x - margin) 826 | let value = -(contentSize.width - contentOffsetMaxX + adjustedContentInset.right) 827 | return value 828 | } 829 | 830 | } 831 | 832 | } 833 | 834 | /** 835 | Special tricks (swizzling) 836 | */ 837 | extension UIScrollView { 838 | 839 | private enum Associated { 840 | static var _valueContainerAssociated: Void? 841 | static var localContentInset: Void? 842 | static var userContentInset: Void? 843 | } 844 | 845 | /// Returns a Boolean value that indicates wheter swizzling has been completed. 846 | fileprivate static let swizzle: Bool = { 847 | 848 | method_exchangeImplementations( 849 | class_getInstanceMethod(UIScrollView.self, #selector(setter:UIScrollView.contentInset))!, 850 | class_getInstanceMethod( 851 | UIScrollView.self, 852 | #selector(UIScrollView.__scrollEdgeControl_setContentInset) 853 | )! 854 | ) 855 | 856 | method_exchangeImplementations( 857 | class_getInstanceMethod(UIScrollView.self, #selector(getter:UIScrollView.contentInset))!, 858 | class_getInstanceMethod( 859 | UIScrollView.self, 860 | #selector(UIScrollView.__scrollEdgeControl_contentInset) 861 | )! 862 | ) 863 | 864 | return true 865 | 866 | }() 867 | 868 | private final class Handlers { 869 | var onSetContentInset: ((UIScrollView, UIEdgeInsets) -> UIEdgeInsets)? 870 | var onGetContentInset: ((UIScrollView, UIEdgeInsets) -> UIEdgeInsets)? 871 | } 872 | 873 | fileprivate(set) var _scrollEdgeControl_localContentInset: UIEdgeInsets { 874 | get { 875 | return (objc_getAssociatedObject(self, &Associated.localContentInset) as? UIEdgeInsets) 876 | ?? .zero 877 | } 878 | set { 879 | 880 | if handlers.onSetContentInset == nil { 881 | handlers.onSetContentInset = { [weak self] scrollView, contentInset in 882 | guard let self = self else { return scrollView.contentInset } 883 | return contentInset.adding(self._scrollEdgeControl_localContentInset) 884 | } 885 | } 886 | 887 | if handlers.onGetContentInset == nil { 888 | handlers.onGetContentInset = { [weak self] scrollView, originalContentInset in 889 | guard let self = self else { return scrollView.contentInset } 890 | return self._userContentInset 891 | } 892 | } 893 | 894 | objc_setAssociatedObject( 895 | self, 896 | &Associated.localContentInset, 897 | newValue, 898 | .OBJC_ASSOCIATION_COPY_NONATOMIC 899 | ) 900 | } 901 | } 902 | 903 | private var handlers: Handlers { 904 | 905 | assert(Thread.isMainThread) 906 | 907 | if let associated = objc_getAssociatedObject(self, &Associated._valueContainerAssociated) 908 | as? Handlers 909 | { 910 | return associated 911 | } else { 912 | let associated = Handlers() 913 | 914 | objc_setAssociatedObject( 915 | self, 916 | &Associated._valueContainerAssociated, 917 | associated, 918 | .OBJC_ASSOCIATION_RETAIN 919 | ) 920 | return associated 921 | } 922 | } 923 | 924 | var __original_contentInset: UIEdgeInsets { 925 | get { 926 | /// call UIScrollView.contentInset.get 927 | __scrollEdgeControl_contentInset() 928 | } 929 | set { 930 | /// call UIScrollView.contentInset.set 931 | __scrollEdgeControl_setContentInset(newValue) 932 | } 933 | } 934 | 935 | /// content-inset without local-content-inset 936 | fileprivate var _userContentInset: UIEdgeInsets { 937 | get { 938 | return (objc_getAssociatedObject(self, &Associated.userContentInset) as? UIEdgeInsets) 939 | ?? .zero 940 | } 941 | set { 942 | objc_setAssociatedObject( 943 | self, 944 | &Associated.userContentInset, 945 | newValue, 946 | .OBJC_ASSOCIATION_COPY_NONATOMIC 947 | ) 948 | } 949 | } 950 | 951 | /// [swizzling] 952 | /// Called from `UIScrollView.contentInset.set` 953 | @objc private dynamic func __scrollEdgeControl_setContentInset(_ contentInset: UIEdgeInsets) { 954 | 955 | ScrollEdgeControl.Log.debug("Set contentInset \(contentInset)") 956 | 957 | _userContentInset = contentInset 958 | 959 | guard let handler = handlers.onSetContentInset else { 960 | self.__scrollEdgeControl_setContentInset(contentInset) 961 | return 962 | } 963 | 964 | let manipulated = handler(self, contentInset) 965 | 966 | /// call actual method 967 | self.__scrollEdgeControl_setContentInset(manipulated) 968 | } 969 | 970 | @objc private dynamic func __scrollEdgeControl_contentInset() -> UIEdgeInsets { 971 | 972 | /// call actual method 973 | let originalContentInset = self.__scrollEdgeControl_contentInset() 974 | 975 | guard let handler = handlers.onGetContentInset else { 976 | /// call actual method 977 | return self.__scrollEdgeControl_contentInset() 978 | } 979 | 980 | return handler(self, originalContentInset) 981 | } 982 | 983 | } 984 | 985 | extension UIEdgeInsets { 986 | 987 | subscript(edge edge: ScrollEdgeControl.Edge) -> CGFloat { 988 | _read { 989 | switch edge { 990 | case .top: yield top 991 | case .right: yield right 992 | case .left: yield left 993 | case .bottom: yield bottom 994 | } 995 | } 996 | _modify { 997 | switch edge { 998 | case .top: 999 | yield &top 1000 | case .right: 1001 | yield &right 1002 | case .left: 1003 | yield &left 1004 | case .bottom: 1005 | yield &bottom 1006 | } 1007 | 1008 | } 1009 | } 1010 | } 1011 | 1012 | private final class ScrollController { 1013 | 1014 | private var scrollObserver: NSKeyValueObservation! 1015 | private(set) var isLocking: Bool = false 1016 | private var previousValue: CGPoint? 1017 | 1018 | init( 1019 | scrollView: UIScrollView 1020 | ) { 1021 | scrollObserver = scrollView.observe(\.contentOffset, options: .old) { 1022 | [weak self, weak _scrollView = scrollView] scrollView, change in 1023 | 1024 | guard let scrollView = _scrollView else { return } 1025 | guard let self = self else { return } 1026 | self.handleScrollViewEvent(scrollView: scrollView, change: change) 1027 | } 1028 | } 1029 | 1030 | deinit { 1031 | endTracking() 1032 | } 1033 | 1034 | func lockScrolling() { 1035 | isLocking = true 1036 | } 1037 | 1038 | func unlockScrolling() { 1039 | isLocking = false 1040 | } 1041 | 1042 | func endTracking() { 1043 | unlockScrolling() 1044 | scrollObserver.invalidate() 1045 | } 1046 | 1047 | private func handleScrollViewEvent( 1048 | scrollView: UIScrollView, 1049 | change: NSKeyValueObservedChange 1050 | ) { 1051 | 1052 | guard let oldValue = change.oldValue else { return } 1053 | 1054 | guard isLocking else { 1055 | return 1056 | } 1057 | 1058 | guard scrollView.contentOffset != oldValue else { return } 1059 | 1060 | guard oldValue != previousValue else { return } 1061 | 1062 | previousValue = scrollView.contentOffset 1063 | 1064 | scrollView.setContentOffset(oldValue, animated: false) 1065 | } 1066 | 1067 | } 1068 | 1069 | extension CGPoint { 1070 | 1071 | subscript(direction direction: ScrollEdgeControl.Edge.Direction) -> CGFloat { 1072 | get { 1073 | switch direction { 1074 | case .vertical: 1075 | return y 1076 | case .horizontal: 1077 | return x 1078 | } 1079 | } 1080 | mutating set { 1081 | switch direction { 1082 | case .vertical: 1083 | y = newValue 1084 | case .horizontal: 1085 | x = newValue 1086 | } 1087 | } 1088 | } 1089 | } 1090 | 1091 | extension UIEdgeInsets { 1092 | 1093 | fileprivate func adding(_ otherInsets: UIEdgeInsets) -> UIEdgeInsets { 1094 | return .init( 1095 | top: top + otherInsets.top, 1096 | left: left + otherInsets.left, 1097 | bottom: bottom + otherInsets.bottom, 1098 | right: right + otherInsets.right 1099 | ) 1100 | } 1101 | 1102 | fileprivate func subtracting(_ otherInsets: UIEdgeInsets) -> UIEdgeInsets { 1103 | return .init( 1104 | top: top - otherInsets.top, 1105 | left: left - otherInsets.left, 1106 | bottom: bottom - otherInsets.bottom, 1107 | right: right - otherInsets.right 1108 | ) 1109 | } 1110 | 1111 | } 1112 | -------------------------------------------------------------------------------- /ScrollEdgeControl.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 4B39D04F2752740100D013F4 /* StackScrollView in Frameworks */ = {isa = PBXBuildFile; productRef = 4B39D04E2752740100D013F4 /* StackScrollView */; }; 11 | 4B39D0512752745C00D013F4 /* DemoHorizontalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B39D0502752745C00D013F4 /* DemoHorizontalViewController.swift */; }; 12 | 4B39D0532752746600D013F4 /* DemoVerticalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B39D0522752746600D013F4 /* DemoVerticalViewController.swift */; }; 13 | 4B39D05527528A1900D013F4 /* DebuggingRefreshIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B39D05427528A1900D013F4 /* DebuggingRefreshIndicatorView.swift */; }; 14 | 4B39D05727528ACD00D013F4 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B39D05627528ACD00D013F4 /* Components.swift */; }; 15 | 4B39D05E2753942F00D013F4 /* DonutsIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5E52E127515DE30075AE52 /* DonutsIndicatorView.swift */; }; 16 | 4B39D05F2753948300D013F4 /* ScrollEdgeActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5E52DF27515DC30075AE52 /* ScrollEdgeActivityIndicatorView.swift */; }; 17 | 4B5E52D527515C830075AE52 /* Book.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5E52D427515C830075AE52 /* Book.swift */; }; 18 | 4B5E52D727515C900075AE52 /* BookContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5E52D627515C900075AE52 /* BookContainerViewController.swift */; }; 19 | 4B5E52D827515CF20075AE52 /* ScrollEdgeControl.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4BC42825275157080047A850 /* ScrollEdgeControl.framework */; }; 20 | 4B5E52D927515CF20075AE52 /* ScrollEdgeControl.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4BC42825275157080047A850 /* ScrollEdgeControl.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 21 | 4B5E52E527515E0A0075AE52 /* MondrianLayout in Frameworks */ = {isa = PBXBuildFile; productRef = 4B5E52E427515E0A0075AE52 /* MondrianLayout */; }; 22 | 4B66215F27515AD100509356 /* TypedTextAttributes in Frameworks */ = {isa = PBXBuildFile; productRef = 4B66215E27515AD100509356 /* TypedTextAttributes */; }; 23 | 4B66216227515B1900509356 /* StorybookKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4B66216127515B1900509356 /* StorybookKit */; }; 24 | 4B66216427515B1900509356 /* StorybookUI in Frameworks */ = {isa = PBXBuildFile; productRef = 4B66216327515B1900509356 /* StorybookUI */; }; 25 | 4B98480627F31E8300ED3FA9 /* ScrollStickyVerticalHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B98480527F31E8300ED3FA9 /* ScrollStickyVerticalHeaderView.swift */; }; 26 | 4B98480927F33C5200ED3FA9 /* CompositionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4B98480827F33C5200ED3FA9 /* CompositionKit */; }; 27 | 4B98480B27F33CB000ED3FA9 /* DemoVerticalStickyHeaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B98480A27F33CB000ED3FA9 /* DemoVerticalStickyHeaderViewController.swift */; }; 28 | 4B9A4D0D29BDAFC70043C4B5 /* UIView+Frame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9A4D0C29BDAFC70043C4B5 /* UIView+Frame.swift */; }; 29 | 4BC42830275157320047A850 /* ScrollEdgeControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC4282F275157320047A850 /* ScrollEdgeControl.swift */; }; 30 | 4BC42833275157C00047A850 /* Advance in Frameworks */ = {isa = PBXBuildFile; productRef = 4BC42832275157C00047A850 /* Advance */; }; 31 | 4BC4283B275158240047A850 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC4283A275158240047A850 /* AppDelegate.swift */; }; 32 | 4BC42844275158250047A850 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4BC42843275158250047A850 /* Assets.xcassets */; }; 33 | 4BC42847275158250047A850 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4BC42845275158250047A850 /* LaunchScreen.storyboard */; }; 34 | 4BC42850275158F80047A850 /* RxCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = 4BC4284F275158F80047A850 /* RxCocoa */; }; 35 | 4BC42852275158F80047A850 /* RxRelay in Frameworks */ = {isa = PBXBuildFile; productRef = 4BC42851275158F80047A850 /* RxRelay */; }; 36 | 4BC42854275158F80047A850 /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 4BC42853275158F80047A850 /* RxSwift */; }; 37 | 4BD5536329D5D51400A1EBF3 /* DemoUIRefreshViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD5536229D5D51400A1EBF3 /* DemoUIRefreshViewController.swift */; }; 38 | C400406E29C1A0F1004A834D /* UIControl+Closure.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400406D29C1A0F1004A834D /* UIControl+Closure.swift */; }; 39 | /* End PBXBuildFile section */ 40 | 41 | /* Begin PBXContainerItemProxy section */ 42 | 4B5E52DA27515CF20075AE52 /* PBXContainerItemProxy */ = { 43 | isa = PBXContainerItemProxy; 44 | containerPortal = 4BC4281C275157080047A850 /* Project object */; 45 | proxyType = 1; 46 | remoteGlobalIDString = 4BC42824275157080047A850; 47 | remoteInfo = ScrollEdgeControl; 48 | }; 49 | /* End PBXContainerItemProxy section */ 50 | 51 | /* Begin PBXCopyFilesBuildPhase section */ 52 | 4B5E52DC27515CF20075AE52 /* Embed Frameworks */ = { 53 | isa = PBXCopyFilesBuildPhase; 54 | buildActionMask = 2147483647; 55 | dstPath = ""; 56 | dstSubfolderSpec = 10; 57 | files = ( 58 | 4B5E52D927515CF20075AE52 /* ScrollEdgeControl.framework in Embed Frameworks */, 59 | ); 60 | name = "Embed Frameworks"; 61 | runOnlyForDeploymentPostprocessing = 0; 62 | }; 63 | /* End PBXCopyFilesBuildPhase section */ 64 | 65 | /* Begin PBXFileReference section */ 66 | 4B39D0502752745C00D013F4 /* DemoHorizontalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoHorizontalViewController.swift; sourceTree = ""; }; 67 | 4B39D0522752746600D013F4 /* DemoVerticalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoVerticalViewController.swift; sourceTree = ""; }; 68 | 4B39D05427528A1900D013F4 /* DebuggingRefreshIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingRefreshIndicatorView.swift; sourceTree = ""; }; 69 | 4B39D05627528ACD00D013F4 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; 70 | 4B5E52D427515C830075AE52 /* Book.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Book.swift; sourceTree = ""; }; 71 | 4B5E52D627515C900075AE52 /* BookContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookContainerViewController.swift; sourceTree = ""; }; 72 | 4B5E52DF27515DC30075AE52 /* ScrollEdgeActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollEdgeActivityIndicatorView.swift; sourceTree = ""; }; 73 | 4B5E52E127515DE30075AE52 /* DonutsIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonutsIndicatorView.swift; sourceTree = ""; }; 74 | 4B98480527F31E8300ED3FA9 /* ScrollStickyVerticalHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollStickyVerticalHeaderView.swift; sourceTree = ""; }; 75 | 4B98480A27F33CB000ED3FA9 /* DemoVerticalStickyHeaderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoVerticalStickyHeaderViewController.swift; sourceTree = ""; }; 76 | 4B9A4D0C29BDAFC70043C4B5 /* UIView+Frame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Frame.swift"; sourceTree = ""; }; 77 | 4BC42825275157080047A850 /* ScrollEdgeControl.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ScrollEdgeControl.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 78 | 4BC4282F275157320047A850 /* ScrollEdgeControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollEdgeControl.swift; sourceTree = ""; }; 79 | 4BC42838275158240047A850 /* ScrollEdgeControl-Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ScrollEdgeControl-Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 80 | 4BC4283A275158240047A850 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 81 | 4BC42843275158250047A850 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 82 | 4BC42846275158250047A850 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 83 | 4BC42848275158250047A850 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 84 | 4BD5536229D5D51400A1EBF3 /* DemoUIRefreshViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoUIRefreshViewController.swift; sourceTree = ""; }; 85 | C400406D29C1A0F1004A834D /* UIControl+Closure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIControl+Closure.swift"; sourceTree = ""; }; 86 | /* End PBXFileReference section */ 87 | 88 | /* Begin PBXFrameworksBuildPhase section */ 89 | 4BC42822275157080047A850 /* Frameworks */ = { 90 | isa = PBXFrameworksBuildPhase; 91 | buildActionMask = 2147483647; 92 | files = ( 93 | 4BC42833275157C00047A850 /* Advance in Frameworks */, 94 | ); 95 | runOnlyForDeploymentPostprocessing = 0; 96 | }; 97 | 4BC42835275158240047A850 /* Frameworks */ = { 98 | isa = PBXFrameworksBuildPhase; 99 | buildActionMask = 2147483647; 100 | files = ( 101 | 4B66216227515B1900509356 /* StorybookKit in Frameworks */, 102 | 4BC42854275158F80047A850 /* RxSwift in Frameworks */, 103 | 4B66215F27515AD100509356 /* TypedTextAttributes in Frameworks */, 104 | 4BC42852275158F80047A850 /* RxRelay in Frameworks */, 105 | 4B5E52D827515CF20075AE52 /* ScrollEdgeControl.framework in Frameworks */, 106 | 4B39D04F2752740100D013F4 /* StackScrollView in Frameworks */, 107 | 4B98480927F33C5200ED3FA9 /* CompositionKit in Frameworks */, 108 | 4BC42850275158F80047A850 /* RxCocoa in Frameworks */, 109 | 4B5E52E527515E0A0075AE52 /* MondrianLayout in Frameworks */, 110 | 4B66216427515B1900509356 /* StorybookUI in Frameworks */, 111 | ); 112 | runOnlyForDeploymentPostprocessing = 0; 113 | }; 114 | /* End PBXFrameworksBuildPhase section */ 115 | 116 | /* Begin PBXGroup section */ 117 | 4B39D0602753948D00D013F4 /* Core */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | 4BC4282F275157320047A850 /* ScrollEdgeControl.swift */, 121 | 4B98480527F31E8300ED3FA9 /* ScrollStickyVerticalHeaderView.swift */, 122 | 4B9A4D0C29BDAFC70043C4B5 /* UIView+Frame.swift */, 123 | ); 124 | path = Core; 125 | sourceTree = ""; 126 | }; 127 | 4B39D0612753949200D013F4 /* Library */ = { 128 | isa = PBXGroup; 129 | children = ( 130 | 4B5E52E127515DE30075AE52 /* DonutsIndicatorView.swift */, 131 | 4B5E52DF27515DC30075AE52 /* ScrollEdgeActivityIndicatorView.swift */, 132 | ); 133 | path = Library; 134 | sourceTree = ""; 135 | }; 136 | 4B98480727F33C5200ED3FA9 /* Frameworks */ = { 137 | isa = PBXGroup; 138 | children = ( 139 | ); 140 | name = Frameworks; 141 | sourceTree = ""; 142 | }; 143 | 4BC4281B275157080047A850 = { 144 | isa = PBXGroup; 145 | children = ( 146 | 4BC42827275157080047A850 /* ScrollEdgeControl */, 147 | 4BC42839275158240047A850 /* ScrollEdgeControl-Demo */, 148 | 4BC42826275157080047A850 /* Products */, 149 | 4B98480727F33C5200ED3FA9 /* Frameworks */, 150 | ); 151 | sourceTree = ""; 152 | }; 153 | 4BC42826275157080047A850 /* Products */ = { 154 | isa = PBXGroup; 155 | children = ( 156 | 4BC42825275157080047A850 /* ScrollEdgeControl.framework */, 157 | 4BC42838275158240047A850 /* ScrollEdgeControl-Demo.app */, 158 | ); 159 | name = Products; 160 | sourceTree = ""; 161 | }; 162 | 4BC42827275157080047A850 /* ScrollEdgeControl */ = { 163 | isa = PBXGroup; 164 | children = ( 165 | 4B39D0602753948D00D013F4 /* Core */, 166 | 4B39D0612753949200D013F4 /* Library */, 167 | ); 168 | path = ScrollEdgeControl; 169 | sourceTree = ""; 170 | }; 171 | 4BC42839275158240047A850 /* ScrollEdgeControl-Demo */ = { 172 | isa = PBXGroup; 173 | children = ( 174 | 4B39D05427528A1900D013F4 /* DebuggingRefreshIndicatorView.swift */, 175 | 4B39D0502752745C00D013F4 /* DemoHorizontalViewController.swift */, 176 | 4B39D0522752746600D013F4 /* DemoVerticalViewController.swift */, 177 | 4B98480A27F33CB000ED3FA9 /* DemoVerticalStickyHeaderViewController.swift */, 178 | 4BC4283A275158240047A850 /* AppDelegate.swift */, 179 | C400406D29C1A0F1004A834D /* UIControl+Closure.swift */, 180 | 4B5E52D427515C830075AE52 /* Book.swift */, 181 | 4B5E52D627515C900075AE52 /* BookContainerViewController.swift */, 182 | 4BC42843275158250047A850 /* Assets.xcassets */, 183 | 4BC42845275158250047A850 /* LaunchScreen.storyboard */, 184 | 4BC42848275158250047A850 /* Info.plist */, 185 | 4B39D05627528ACD00D013F4 /* Components.swift */, 186 | 4BD5536229D5D51400A1EBF3 /* DemoUIRefreshViewController.swift */, 187 | ); 188 | path = "ScrollEdgeControl-Demo"; 189 | sourceTree = ""; 190 | }; 191 | /* End PBXGroup section */ 192 | 193 | /* Begin PBXHeadersBuildPhase section */ 194 | 4BC42820275157080047A850 /* Headers */ = { 195 | isa = PBXHeadersBuildPhase; 196 | buildActionMask = 2147483647; 197 | files = ( 198 | ); 199 | runOnlyForDeploymentPostprocessing = 0; 200 | }; 201 | /* End PBXHeadersBuildPhase section */ 202 | 203 | /* Begin PBXNativeTarget section */ 204 | 4BC42824275157080047A850 /* ScrollEdgeControl */ = { 205 | isa = PBXNativeTarget; 206 | buildConfigurationList = 4BC4282C275157080047A850 /* Build configuration list for PBXNativeTarget "ScrollEdgeControl" */; 207 | buildPhases = ( 208 | 4BC42820275157080047A850 /* Headers */, 209 | 4BC42821275157080047A850 /* Sources */, 210 | 4BC42822275157080047A850 /* Frameworks */, 211 | 4BC42823275157080047A850 /* Resources */, 212 | ); 213 | buildRules = ( 214 | ); 215 | dependencies = ( 216 | ); 217 | name = ScrollEdgeControl; 218 | packageProductDependencies = ( 219 | 4BC42832275157C00047A850 /* Advance */, 220 | ); 221 | productName = ScrollEdgeControl; 222 | productReference = 4BC42825275157080047A850 /* ScrollEdgeControl.framework */; 223 | productType = "com.apple.product-type.framework"; 224 | }; 225 | 4BC42837275158240047A850 /* ScrollEdgeControl-Demo */ = { 226 | isa = PBXNativeTarget; 227 | buildConfigurationList = 4BC42849275158250047A850 /* Build configuration list for PBXNativeTarget "ScrollEdgeControl-Demo" */; 228 | buildPhases = ( 229 | 4BC42834275158240047A850 /* Sources */, 230 | 4BC42835275158240047A850 /* Frameworks */, 231 | 4BC42836275158240047A850 /* Resources */, 232 | 4B5E52DC27515CF20075AE52 /* Embed Frameworks */, 233 | ); 234 | buildRules = ( 235 | ); 236 | dependencies = ( 237 | 4B5E52DB27515CF20075AE52 /* PBXTargetDependency */, 238 | ); 239 | name = "ScrollEdgeControl-Demo"; 240 | packageProductDependencies = ( 241 | 4BC4284F275158F80047A850 /* RxCocoa */, 242 | 4BC42851275158F80047A850 /* RxRelay */, 243 | 4BC42853275158F80047A850 /* RxSwift */, 244 | 4B66215E27515AD100509356 /* TypedTextAttributes */, 245 | 4B66216127515B1900509356 /* StorybookKit */, 246 | 4B66216327515B1900509356 /* StorybookUI */, 247 | 4B5E52E427515E0A0075AE52 /* MondrianLayout */, 248 | 4B39D04E2752740100D013F4 /* StackScrollView */, 249 | 4B98480827F33C5200ED3FA9 /* CompositionKit */, 250 | ); 251 | productName = "ScrollEdgeControl-Demo"; 252 | productReference = 4BC42838275158240047A850 /* ScrollEdgeControl-Demo.app */; 253 | productType = "com.apple.product-type.application"; 254 | }; 255 | /* End PBXNativeTarget section */ 256 | 257 | /* Begin PBXProject section */ 258 | 4BC4281C275157080047A850 /* Project object */ = { 259 | isa = PBXProject; 260 | attributes = { 261 | BuildIndependentTargetsInParallel = 1; 262 | LastSwiftUpdateCheck = 1320; 263 | LastUpgradeCheck = 1320; 264 | TargetAttributes = { 265 | 4BC42824275157080047A850 = { 266 | CreatedOnToolsVersion = 13.2; 267 | }; 268 | 4BC42837275158240047A850 = { 269 | CreatedOnToolsVersion = 13.2; 270 | }; 271 | }; 272 | }; 273 | buildConfigurationList = 4BC4281F275157080047A850 /* Build configuration list for PBXProject "ScrollEdgeControl" */; 274 | compatibilityVersion = "Xcode 13.0"; 275 | developmentRegion = en; 276 | hasScannedForEncodings = 0; 277 | knownRegions = ( 278 | en, 279 | Base, 280 | ); 281 | mainGroup = 4BC4281B275157080047A850; 282 | packageReferences = ( 283 | 4BC42831275157C00047A850 /* XCRemoteSwiftPackageReference "Advance" */, 284 | 4BC4284E275158F80047A850 /* XCRemoteSwiftPackageReference "RxSwift" */, 285 | 4B66215D27515AD100509356 /* XCRemoteSwiftPackageReference "TypedTextAttributes" */, 286 | 4B66216027515B1800509356 /* XCRemoteSwiftPackageReference "Storybook-ios" */, 287 | 4B5E52E327515E0A0075AE52 /* XCRemoteSwiftPackageReference "MondrianLayout" */, 288 | 4B39D04D2752740000D013F4 /* XCRemoteSwiftPackageReference "StackScrollView" */, 289 | 4B6ABA282796E28600D9BFC6 /* XCRemoteSwiftPackageReference "CompositionKit" */, 290 | ); 291 | productRefGroup = 4BC42826275157080047A850 /* Products */; 292 | projectDirPath = ""; 293 | projectRoot = ""; 294 | targets = ( 295 | 4BC42824275157080047A850 /* ScrollEdgeControl */, 296 | 4BC42837275158240047A850 /* ScrollEdgeControl-Demo */, 297 | ); 298 | }; 299 | /* End PBXProject section */ 300 | 301 | /* Begin PBXResourcesBuildPhase section */ 302 | 4BC42823275157080047A850 /* Resources */ = { 303 | isa = PBXResourcesBuildPhase; 304 | buildActionMask = 2147483647; 305 | files = ( 306 | ); 307 | runOnlyForDeploymentPostprocessing = 0; 308 | }; 309 | 4BC42836275158240047A850 /* Resources */ = { 310 | isa = PBXResourcesBuildPhase; 311 | buildActionMask = 2147483647; 312 | files = ( 313 | 4BC42847275158250047A850 /* LaunchScreen.storyboard in Resources */, 314 | 4BC42844275158250047A850 /* Assets.xcassets in Resources */, 315 | ); 316 | runOnlyForDeploymentPostprocessing = 0; 317 | }; 318 | /* End PBXResourcesBuildPhase section */ 319 | 320 | /* Begin PBXSourcesBuildPhase section */ 321 | 4BC42821275157080047A850 /* Sources */ = { 322 | isa = PBXSourcesBuildPhase; 323 | buildActionMask = 2147483647; 324 | files = ( 325 | 4BC42830275157320047A850 /* ScrollEdgeControl.swift in Sources */, 326 | 4B9A4D0D29BDAFC70043C4B5 /* UIView+Frame.swift in Sources */, 327 | 4B39D05F2753948300D013F4 /* ScrollEdgeActivityIndicatorView.swift in Sources */, 328 | 4B98480627F31E8300ED3FA9 /* ScrollStickyVerticalHeaderView.swift in Sources */, 329 | 4B39D05E2753942F00D013F4 /* DonutsIndicatorView.swift in Sources */, 330 | ); 331 | runOnlyForDeploymentPostprocessing = 0; 332 | }; 333 | 4BC42834275158240047A850 /* Sources */ = { 334 | isa = PBXSourcesBuildPhase; 335 | buildActionMask = 2147483647; 336 | files = ( 337 | 4BC4283B275158240047A850 /* AppDelegate.swift in Sources */, 338 | 4B5E52D727515C900075AE52 /* BookContainerViewController.swift in Sources */, 339 | 4B5E52D527515C830075AE52 /* Book.swift in Sources */, 340 | 4B39D0532752746600D013F4 /* DemoVerticalViewController.swift in Sources */, 341 | 4B39D05727528ACD00D013F4 /* Components.swift in Sources */, 342 | C400406E29C1A0F1004A834D /* UIControl+Closure.swift in Sources */, 343 | 4B39D0512752745C00D013F4 /* DemoHorizontalViewController.swift in Sources */, 344 | 4BD5536329D5D51400A1EBF3 /* DemoUIRefreshViewController.swift in Sources */, 345 | 4B39D05527528A1900D013F4 /* DebuggingRefreshIndicatorView.swift in Sources */, 346 | 4B98480B27F33CB000ED3FA9 /* DemoVerticalStickyHeaderViewController.swift in Sources */, 347 | ); 348 | runOnlyForDeploymentPostprocessing = 0; 349 | }; 350 | /* End PBXSourcesBuildPhase section */ 351 | 352 | /* Begin PBXTargetDependency section */ 353 | 4B5E52DB27515CF20075AE52 /* PBXTargetDependency */ = { 354 | isa = PBXTargetDependency; 355 | target = 4BC42824275157080047A850 /* ScrollEdgeControl */; 356 | targetProxy = 4B5E52DA27515CF20075AE52 /* PBXContainerItemProxy */; 357 | }; 358 | /* End PBXTargetDependency section */ 359 | 360 | /* Begin PBXVariantGroup section */ 361 | 4BC42845275158250047A850 /* LaunchScreen.storyboard */ = { 362 | isa = PBXVariantGroup; 363 | children = ( 364 | 4BC42846275158250047A850 /* Base */, 365 | ); 366 | name = LaunchScreen.storyboard; 367 | sourceTree = ""; 368 | }; 369 | /* End PBXVariantGroup section */ 370 | 371 | /* Begin XCBuildConfiguration section */ 372 | 4BC4282A275157080047A850 /* Debug */ = { 373 | isa = XCBuildConfiguration; 374 | buildSettings = { 375 | ALWAYS_SEARCH_USER_PATHS = NO; 376 | CLANG_ANALYZER_NONNULL = YES; 377 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 378 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 379 | CLANG_CXX_LIBRARY = "libc++"; 380 | CLANG_ENABLE_MODULES = YES; 381 | CLANG_ENABLE_OBJC_ARC = YES; 382 | CLANG_ENABLE_OBJC_WEAK = YES; 383 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 384 | CLANG_WARN_BOOL_CONVERSION = YES; 385 | CLANG_WARN_COMMA = YES; 386 | CLANG_WARN_CONSTANT_CONVERSION = YES; 387 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 388 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 389 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 390 | CLANG_WARN_EMPTY_BODY = YES; 391 | CLANG_WARN_ENUM_CONVERSION = YES; 392 | CLANG_WARN_INFINITE_RECURSION = YES; 393 | CLANG_WARN_INT_CONVERSION = YES; 394 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 395 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 396 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 397 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 398 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 399 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 400 | CLANG_WARN_STRICT_PROTOTYPES = YES; 401 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 402 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 403 | CLANG_WARN_UNREACHABLE_CODE = YES; 404 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 405 | COPY_PHASE_STRIP = NO; 406 | CURRENT_PROJECT_VERSION = 1; 407 | DEBUG_INFORMATION_FORMAT = dwarf; 408 | ENABLE_STRICT_OBJC_MSGSEND = YES; 409 | ENABLE_TESTABILITY = YES; 410 | GCC_C_LANGUAGE_STANDARD = gnu11; 411 | GCC_DYNAMIC_NO_PIC = NO; 412 | GCC_NO_COMMON_BLOCKS = YES; 413 | GCC_OPTIMIZATION_LEVEL = 0; 414 | GCC_PREPROCESSOR_DEFINITIONS = ( 415 | "DEBUG=1", 416 | "$(inherited)", 417 | ); 418 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 419 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 420 | GCC_WARN_UNDECLARED_SELECTOR = YES; 421 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 422 | GCC_WARN_UNUSED_FUNCTION = YES; 423 | GCC_WARN_UNUSED_VARIABLE = YES; 424 | IPHONEOS_DEPLOYMENT_TARGET = 12; 425 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 426 | MTL_FAST_MATH = YES; 427 | ONLY_ACTIVE_ARCH = YES; 428 | SDKROOT = iphoneos; 429 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG SCROLLEDGECONTROL_LOG_ENABLED"; 430 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 431 | VERSIONING_SYSTEM = "apple-generic"; 432 | VERSION_INFO_PREFIX = ""; 433 | }; 434 | name = Debug; 435 | }; 436 | 4BC4282B275157080047A850 /* Release */ = { 437 | isa = XCBuildConfiguration; 438 | buildSettings = { 439 | ALWAYS_SEARCH_USER_PATHS = NO; 440 | CLANG_ANALYZER_NONNULL = YES; 441 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 442 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 443 | CLANG_CXX_LIBRARY = "libc++"; 444 | CLANG_ENABLE_MODULES = YES; 445 | CLANG_ENABLE_OBJC_ARC = YES; 446 | CLANG_ENABLE_OBJC_WEAK = YES; 447 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 448 | CLANG_WARN_BOOL_CONVERSION = YES; 449 | CLANG_WARN_COMMA = YES; 450 | CLANG_WARN_CONSTANT_CONVERSION = YES; 451 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 452 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 453 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 454 | CLANG_WARN_EMPTY_BODY = YES; 455 | CLANG_WARN_ENUM_CONVERSION = YES; 456 | CLANG_WARN_INFINITE_RECURSION = YES; 457 | CLANG_WARN_INT_CONVERSION = YES; 458 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 459 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 460 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 461 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 462 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 463 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 464 | CLANG_WARN_STRICT_PROTOTYPES = YES; 465 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 466 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 467 | CLANG_WARN_UNREACHABLE_CODE = YES; 468 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 469 | COPY_PHASE_STRIP = NO; 470 | CURRENT_PROJECT_VERSION = 1; 471 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 472 | ENABLE_NS_ASSERTIONS = NO; 473 | ENABLE_STRICT_OBJC_MSGSEND = YES; 474 | GCC_C_LANGUAGE_STANDARD = gnu11; 475 | GCC_NO_COMMON_BLOCKS = YES; 476 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 477 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 478 | GCC_WARN_UNDECLARED_SELECTOR = YES; 479 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 480 | GCC_WARN_UNUSED_FUNCTION = YES; 481 | GCC_WARN_UNUSED_VARIABLE = YES; 482 | IPHONEOS_DEPLOYMENT_TARGET = 12; 483 | MTL_ENABLE_DEBUG_INFO = NO; 484 | MTL_FAST_MATH = YES; 485 | SDKROOT = iphoneos; 486 | SWIFT_COMPILATION_MODE = wholemodule; 487 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 488 | VALIDATE_PRODUCT = YES; 489 | VERSIONING_SYSTEM = "apple-generic"; 490 | VERSION_INFO_PREFIX = ""; 491 | }; 492 | name = Release; 493 | }; 494 | 4BC4282D275157080047A850 /* Debug */ = { 495 | isa = XCBuildConfiguration; 496 | buildSettings = { 497 | CODE_SIGN_STYLE = Automatic; 498 | CURRENT_PROJECT_VERSION = 1; 499 | DEFINES_MODULE = YES; 500 | DYLIB_COMPATIBILITY_VERSION = 1; 501 | DYLIB_CURRENT_VERSION = 1; 502 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 503 | GENERATE_INFOPLIST_FILE = YES; 504 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 505 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 506 | LD_RUNPATH_SEARCH_PATHS = ( 507 | "$(inherited)", 508 | "@executable_path/Frameworks", 509 | "@loader_path/Frameworks", 510 | ); 511 | MARKETING_VERSION = 1.0; 512 | PRODUCT_BUNDLE_IDENTIFIER = jp.eure.ScrollEdgeControl; 513 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 514 | SKIP_INSTALL = YES; 515 | SWIFT_EMIT_LOC_STRINGS = YES; 516 | SWIFT_VERSION = 5.0; 517 | TARGETED_DEVICE_FAMILY = "1,2"; 518 | }; 519 | name = Debug; 520 | }; 521 | 4BC4282E275157080047A850 /* Release */ = { 522 | isa = XCBuildConfiguration; 523 | buildSettings = { 524 | CODE_SIGN_STYLE = Automatic; 525 | CURRENT_PROJECT_VERSION = 1; 526 | DEFINES_MODULE = YES; 527 | DYLIB_COMPATIBILITY_VERSION = 1; 528 | DYLIB_CURRENT_VERSION = 1; 529 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 530 | GENERATE_INFOPLIST_FILE = YES; 531 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 532 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 533 | LD_RUNPATH_SEARCH_PATHS = ( 534 | "$(inherited)", 535 | "@executable_path/Frameworks", 536 | "@loader_path/Frameworks", 537 | ); 538 | MARKETING_VERSION = 1.0; 539 | PRODUCT_BUNDLE_IDENTIFIER = jp.eure.ScrollEdgeControl; 540 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 541 | SKIP_INSTALL = YES; 542 | SWIFT_EMIT_LOC_STRINGS = YES; 543 | SWIFT_VERSION = 5.0; 544 | TARGETED_DEVICE_FAMILY = "1,2"; 545 | }; 546 | name = Release; 547 | }; 548 | 4BC4284A275158250047A850 /* Debug */ = { 549 | isa = XCBuildConfiguration; 550 | buildSettings = { 551 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 553 | CODE_SIGN_STYLE = Automatic; 554 | CURRENT_PROJECT_VERSION = 1; 555 | DEVELOPMENT_TEAM = JX92XL88RZ; 556 | GENERATE_INFOPLIST_FILE = YES; 557 | INFOPLIST_FILE = "ScrollEdgeControl-Demo/Info.plist"; 558 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 559 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 560 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 561 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 562 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 563 | LD_RUNPATH_SEARCH_PATHS = ( 564 | "$(inherited)", 565 | "@executable_path/Frameworks", 566 | ); 567 | MARKETING_VERSION = 1.0; 568 | PRODUCT_BUNDLE_IDENTIFIER = "jp.eure.ScrollEdgeControl-Demo"; 569 | PRODUCT_NAME = "$(TARGET_NAME)"; 570 | SWIFT_EMIT_LOC_STRINGS = YES; 571 | SWIFT_VERSION = 5.0; 572 | TARGETED_DEVICE_FAMILY = "1,2"; 573 | }; 574 | name = Debug; 575 | }; 576 | 4BC4284B275158250047A850 /* Release */ = { 577 | isa = XCBuildConfiguration; 578 | buildSettings = { 579 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 580 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 581 | CODE_SIGN_STYLE = Automatic; 582 | CURRENT_PROJECT_VERSION = 1; 583 | DEVELOPMENT_TEAM = JX92XL88RZ; 584 | GENERATE_INFOPLIST_FILE = YES; 585 | INFOPLIST_FILE = "ScrollEdgeControl-Demo/Info.plist"; 586 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 587 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 588 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 589 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 590 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 591 | LD_RUNPATH_SEARCH_PATHS = ( 592 | "$(inherited)", 593 | "@executable_path/Frameworks", 594 | ); 595 | MARKETING_VERSION = 1.0; 596 | PRODUCT_BUNDLE_IDENTIFIER = "jp.eure.ScrollEdgeControl-Demo"; 597 | PRODUCT_NAME = "$(TARGET_NAME)"; 598 | SWIFT_EMIT_LOC_STRINGS = YES; 599 | SWIFT_VERSION = 5.0; 600 | TARGETED_DEVICE_FAMILY = "1,2"; 601 | }; 602 | name = Release; 603 | }; 604 | /* End XCBuildConfiguration section */ 605 | 606 | /* Begin XCConfigurationList section */ 607 | 4BC4281F275157080047A850 /* Build configuration list for PBXProject "ScrollEdgeControl" */ = { 608 | isa = XCConfigurationList; 609 | buildConfigurations = ( 610 | 4BC4282A275157080047A850 /* Debug */, 611 | 4BC4282B275157080047A850 /* Release */, 612 | ); 613 | defaultConfigurationIsVisible = 0; 614 | defaultConfigurationName = Release; 615 | }; 616 | 4BC4282C275157080047A850 /* Build configuration list for PBXNativeTarget "ScrollEdgeControl" */ = { 617 | isa = XCConfigurationList; 618 | buildConfigurations = ( 619 | 4BC4282D275157080047A850 /* Debug */, 620 | 4BC4282E275157080047A850 /* Release */, 621 | ); 622 | defaultConfigurationIsVisible = 0; 623 | defaultConfigurationName = Release; 624 | }; 625 | 4BC42849275158250047A850 /* Build configuration list for PBXNativeTarget "ScrollEdgeControl-Demo" */ = { 626 | isa = XCConfigurationList; 627 | buildConfigurations = ( 628 | 4BC4284A275158250047A850 /* Debug */, 629 | 4BC4284B275158250047A850 /* Release */, 630 | ); 631 | defaultConfigurationIsVisible = 0; 632 | defaultConfigurationName = Release; 633 | }; 634 | /* End XCConfigurationList section */ 635 | 636 | /* Begin XCRemoteSwiftPackageReference section */ 637 | 4B39D04D2752740000D013F4 /* XCRemoteSwiftPackageReference "StackScrollView" */ = { 638 | isa = XCRemoteSwiftPackageReference; 639 | repositoryURL = "git@github.com:muukii/StackScrollView.git"; 640 | requirement = { 641 | branch = master; 642 | kind = branch; 643 | }; 644 | }; 645 | 4B5E52E327515E0A0075AE52 /* XCRemoteSwiftPackageReference "MondrianLayout" */ = { 646 | isa = XCRemoteSwiftPackageReference; 647 | repositoryURL = "https://github.com/muukii/MondrianLayout.git"; 648 | requirement = { 649 | branch = main; 650 | kind = branch; 651 | }; 652 | }; 653 | 4B66215D27515AD100509356 /* XCRemoteSwiftPackageReference "TypedTextAttributes" */ = { 654 | isa = XCRemoteSwiftPackageReference; 655 | repositoryURL = "git@github.com:muukii/TypedTextAttributes.git"; 656 | requirement = { 657 | kind = upToNextMajorVersion; 658 | minimumVersion = 1.0.0; 659 | }; 660 | }; 661 | 4B66216027515B1800509356 /* XCRemoteSwiftPackageReference "Storybook-ios" */ = { 662 | isa = XCRemoteSwiftPackageReference; 663 | repositoryURL = "git@github.com:eure/Storybook-ios.git"; 664 | requirement = { 665 | kind = upToNextMajorVersion; 666 | minimumVersion = 1.0.0; 667 | }; 668 | }; 669 | 4B6ABA282796E28600D9BFC6 /* XCRemoteSwiftPackageReference "CompositionKit" */ = { 670 | isa = XCRemoteSwiftPackageReference; 671 | repositoryURL = "https://github.com/muukii/CompositionKit.git"; 672 | requirement = { 673 | kind = upToNextMajorVersion; 674 | minimumVersion = 0.2.1; 675 | }; 676 | }; 677 | 4BC42831275157C00047A850 /* XCRemoteSwiftPackageReference "Advance" */ = { 678 | isa = XCRemoteSwiftPackageReference; 679 | repositoryURL = "git@github.com:timdonnelly/Advance.git"; 680 | requirement = { 681 | kind = upToNextMajorVersion; 682 | minimumVersion = 3.0.0; 683 | }; 684 | }; 685 | 4BC4284E275158F80047A850 /* XCRemoteSwiftPackageReference "RxSwift" */ = { 686 | isa = XCRemoteSwiftPackageReference; 687 | repositoryURL = "git@github.com:ReactiveX/RxSwift.git"; 688 | requirement = { 689 | kind = upToNextMajorVersion; 690 | minimumVersion = 6.0.0; 691 | }; 692 | }; 693 | /* End XCRemoteSwiftPackageReference section */ 694 | 695 | /* Begin XCSwiftPackageProductDependency section */ 696 | 4B39D04E2752740100D013F4 /* StackScrollView */ = { 697 | isa = XCSwiftPackageProductDependency; 698 | package = 4B39D04D2752740000D013F4 /* XCRemoteSwiftPackageReference "StackScrollView" */; 699 | productName = StackScrollView; 700 | }; 701 | 4B5E52E427515E0A0075AE52 /* MondrianLayout */ = { 702 | isa = XCSwiftPackageProductDependency; 703 | package = 4B5E52E327515E0A0075AE52 /* XCRemoteSwiftPackageReference "MondrianLayout" */; 704 | productName = MondrianLayout; 705 | }; 706 | 4B66215E27515AD100509356 /* TypedTextAttributes */ = { 707 | isa = XCSwiftPackageProductDependency; 708 | package = 4B66215D27515AD100509356 /* XCRemoteSwiftPackageReference "TypedTextAttributes" */; 709 | productName = TypedTextAttributes; 710 | }; 711 | 4B66216127515B1900509356 /* StorybookKit */ = { 712 | isa = XCSwiftPackageProductDependency; 713 | package = 4B66216027515B1800509356 /* XCRemoteSwiftPackageReference "Storybook-ios" */; 714 | productName = StorybookKit; 715 | }; 716 | 4B66216327515B1900509356 /* StorybookUI */ = { 717 | isa = XCSwiftPackageProductDependency; 718 | package = 4B66216027515B1800509356 /* XCRemoteSwiftPackageReference "Storybook-ios" */; 719 | productName = StorybookUI; 720 | }; 721 | 4B98480827F33C5200ED3FA9 /* CompositionKit */ = { 722 | isa = XCSwiftPackageProductDependency; 723 | package = 4B6ABA282796E28600D9BFC6 /* XCRemoteSwiftPackageReference "CompositionKit" */; 724 | productName = CompositionKit; 725 | }; 726 | 4BC42832275157C00047A850 /* Advance */ = { 727 | isa = XCSwiftPackageProductDependency; 728 | package = 4BC42831275157C00047A850 /* XCRemoteSwiftPackageReference "Advance" */; 729 | productName = Advance; 730 | }; 731 | 4BC4284F275158F80047A850 /* RxCocoa */ = { 732 | isa = XCSwiftPackageProductDependency; 733 | package = 4BC4284E275158F80047A850 /* XCRemoteSwiftPackageReference "RxSwift" */; 734 | productName = RxCocoa; 735 | }; 736 | 4BC42851275158F80047A850 /* RxRelay */ = { 737 | isa = XCSwiftPackageProductDependency; 738 | package = 4BC4284E275158F80047A850 /* XCRemoteSwiftPackageReference "RxSwift" */; 739 | productName = RxRelay; 740 | }; 741 | 4BC42853275158F80047A850 /* RxSwift */ = { 742 | isa = XCSwiftPackageProductDependency; 743 | package = 4BC4284E275158F80047A850 /* XCRemoteSwiftPackageReference "RxSwift" */; 744 | productName = RxSwift; 745 | }; 746 | /* End XCSwiftPackageProductDependency section */ 747 | }; 748 | rootObject = 4BC4281C275157080047A850 /* Project object */; 749 | } 750 | --------------------------------------------------------------------------------