├── 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 |
--------------------------------------------------------------------------------