├── .github
├── FUNDING.yml
└── workflows
│ └── swift.yml
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── SetNeedsDisplay
│ └── SetNeedsDisplay.swift
└── Tests
├── LinuxMain.swift
└── SetNeedsDisplayTests
├── SetNeedsDisplayTests.swift
└── XCTestManifests.swift
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: b3ll
4 |
--------------------------------------------------------------------------------
/.github/workflows/swift.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | build:
11 | runs-on: macos-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: Build
16 | run: swift build -v
17 | - name: Run tests
18 | run: swift test -v
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 2-Clause License
2 |
3 | Copyright (c) 2020, Adam Bell
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SetNeedsDisplay",
8 | platforms: [
9 | .iOS(.v13),
10 | .tvOS(.v13),
11 | .macOS(.v10_15),
12 | ],
13 | products: [
14 | .library(
15 | name: "SetNeedsDisplay",
16 | targets: ["SetNeedsDisplay"]),
17 | ],
18 | dependencies: [],
19 | targets: [
20 | .target(
21 | name: "SetNeedsDisplay",
22 | dependencies: []),
23 | .testTarget(
24 | name: "SetNeedsDisplayTests",
25 | dependencies: ["SetNeedsDisplay"]),
26 | ],
27 | swiftLanguageVersions: [.v5]
28 | )
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SetNeedsDisplay
2 |
3 | 
4 |
5 | This package provides property wrappers that can be used on properties for any `NSView` or `UIView` to invalidate the layout or display whenever the value of said property is changed.
6 |
7 | **Note**: This code contains some private Swift API stuff that powers `@Published` so there's a strong likelihood this will break in the future.
8 |
9 | - [Introduction](#SetNeedsDisplay)
10 | - [Usage](#usage)
11 | - [Installation](#installation)
12 | - [Requirements](#requirements)
13 | - [Swift Package Manager](#swift-package-manager)
14 | - [License](#license)
15 | - [Thanks](#thanks)
16 | - [Contact Info](#contact-info)
17 |
18 | # Usage
19 |
20 | Annotate your property of a type that conforms to `Equatable` like so:
21 |
22 | ```swift
23 | class MyView: UIView {
24 |
25 | // Anytime someCustomProperty is changed, `-setNeedsDisplay` will be called.
26 | @SetNeeds(.display) var someCustomProperty: CGFloat = 0.0
27 |
28 |
29 | // Anytime someOtherCustomProperty is changed, `-setNeedsLayout` will be called.
30 | @SetNeeds(.layout) var someOtherCustomProperty: CGFloat = 0.0
31 |
32 |
33 | // Anytime oneLastProperty is changed, `-setNeedsDisplay` and `-setNeedsLayout` will be called.
34 | @SetNeeds(.display, .layout) var oneLastProperty: CGFloat = 0.0
35 |
36 | }
37 | ```
38 |
39 | # Installation
40 |
41 | ## Requirements
42 |
43 | - iOS 13+, macOS 10.15+
44 | - Swift 5.0 or higher
45 |
46 | Currently SetNeedsDisplay supports Swift Package Manager (or manually adding `SetNeedsDisplay.swift` to your project).
47 |
48 | ## Swift Package Manager
49 |
50 | Add the following to your `Package.swift` (or add it via Xcode's GUI):
51 |
52 | ```swift
53 | .package(url: "https://github.com/b3ll/SetNeedsDisplay", from: "0.0.1")
54 | ```
55 |
56 | # License
57 |
58 | SetNeedsDisplay is licensed under the [BSD 2-clause license](https://github.com/b3ll/SetNeedsDisplay/blob/master/LICENSE).
59 |
60 | # Thanks
61 |
62 | Thanks to [@harlanhaskins](https://twitter.com/harlanhaskins) and [@hollyborla](https://twitter.com/hollyborla) for helping point me in the right direction and explain the complexity that this sort of solution entails.
63 |
64 | More info [here](https://forums.swift.org/t/property-wrappers-access-to-both-enclosing-self-and-wrapper-instance/32526).
65 |
66 | # Contact Info
67 |
68 | Feel free to follow me on twitter: [@b3ll](https://www.twitter.com/b3ll)!
69 |
--------------------------------------------------------------------------------
/Sources/SetNeedsDisplay/SetNeedsDisplay.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit
3 | #else
4 | import UIKit
5 | #endif
6 |
7 | public struct InvalidationOptions: OptionSet {
8 | public let rawValue: Int
9 |
10 | public init(rawValue: Int) {
11 | self.rawValue = rawValue
12 | }
13 |
14 | /// Will call `-setNeedsDisplay` (depending on platform) on property value changes.
15 | public static let display = InvalidationOptions(rawValue: 1 << 0)
16 |
17 | /// Will call `-setNeedsLayout` (depending on platform) on property value changes.
18 | public static let layout = InvalidationOptions(rawValue: 1 << 1)
19 | }
20 |
21 | /**
22 | A property wrapper for `UIView` or `NSView` (depending on platform) that will invalidate aspects of the view (i.e. will call `-setNeedsDisplay`, or `-setNeedsLayout`, etc. on the view) when the property's value is changed to something new.
23 |
24 | ```
25 | class SomeView: UIView {
26 |
27 | @SetNeeds(.layout) var subviewPadding: CGFloat = 0.0
28 |
29 | }
30 | ```
31 | */
32 | @propertyWrapper
33 | public final class SetNeeds where Value: Equatable {
34 |
35 | #if os(macOS)
36 | public typealias ViewType = NSView
37 | #else
38 | public typealias ViewType = UIView
39 | #endif
40 |
41 | private var stored: Value
42 | private let invalidationOptions: InvalidationOptions
43 |
44 | // Heavily inspired by the work done by ebg here: https://forums.swift.org/t/property-wrappers-access-to-both-enclosing-self-and-wrapper-instance/32526
45 | public static subscript(
46 | _enclosingInstance observed: EnclosingSelf,
47 | wrapped wrappedKeyPath: ReferenceWritableKeyPath,
48 | storage storageKeyPath: ReferenceWritableKeyPath
49 | ) -> Value where EnclosingSelf: ViewType {
50 | get {
51 | return observed[keyPath: storageKeyPath].stored
52 | }
53 | set {
54 | let oldValue = observed[keyPath: storageKeyPath].stored
55 |
56 | if newValue != oldValue {
57 | observed[keyPath: storageKeyPath].stored = newValue
58 |
59 | let invalidationOptions = observed[keyPath: storageKeyPath].invalidationOptions
60 |
61 | if invalidationOptions.contains(.display) {
62 | #if os(macOS)
63 | observed.setNeedsDisplay(observed.bounds)
64 | #else
65 | observed.setNeedsDisplay()
66 | #endif
67 | }
68 |
69 | if invalidationOptions.contains(.layout) {
70 | #if os(macOS)
71 | observed.needsLayout = true
72 | #else
73 | observed.setNeedsLayout()
74 | #endif
75 | }
76 | }
77 | }
78 | }
79 |
80 | public var wrappedValue: Value {
81 | get { fatalError("called wrappedValue getter") }
82 | set { fatalError("called wrappedValue setter") }
83 | }
84 |
85 | // IMO @SetNeeds(.display, .layout) looks nicer than @SetNeeds([.display, .layout])
86 | public init(wrappedValue: Value, _ invalidationOptions: InvalidationOptions...) {
87 | self.stored = wrappedValue
88 | self.invalidationOptions = invalidationOptions.reduce(into: []) { $0.insert($1) }
89 | }
90 |
91 | public init(wrappedValue: Value, _ invalidationOptions: InvalidationOptions) {
92 | self.stored = wrappedValue
93 | self.invalidationOptions = invalidationOptions
94 | }
95 |
96 | }
97 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import SetNeedsDisplayTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += SetNeedsDisplayTests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------
/Tests/SetNeedsDisplayTests/SetNeedsDisplayTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import SetNeedsDisplay
3 |
4 | #if os(macOS)
5 | import AppKit
6 |
7 | class TestView: NSView {
8 |
9 | @SetNeeds(.display) var testSetNeedsDisplay: CGFloat = 0.0
10 |
11 | @SetNeeds(.layout) var testSetNeedsLayout: CGFloat = 0.0
12 |
13 | @SetNeeds([.layout, .display]) var testSetNeedsDisplayAndLayout: CGFloat = 0.0
14 |
15 | @SetNeeds(.layout, .display) var testSetNeedsDisplayAndLayout2: CGFloat = 0.0
16 |
17 | var layoutWasInvalidated: Bool = false
18 |
19 | var displayWasInvalidated: Bool = false
20 |
21 | override var needsLayout: Bool {
22 | didSet {
23 | if needsLayout {
24 | self.layoutWasInvalidated = true
25 | }
26 | }
27 | }
28 |
29 | override func setNeedsDisplay(_ invalidRect: NSRect) {
30 | super.setNeedsDisplay(invalidRect)
31 |
32 | self.displayWasInvalidated = true
33 | }
34 |
35 | override func layout() {
36 | super.layout()
37 |
38 | self.layoutWasInvalidated = false
39 | }
40 |
41 | }
42 | #else
43 | import UIKit
44 |
45 | class TestView: UIView {
46 |
47 | @SetNeeds(.display) var testSetNeedsDisplay: CGFloat = 0.0
48 |
49 | @SetNeeds(.layout) var testSetNeedsLayout: CGFloat = 0.0
50 |
51 | @SetNeeds([.layout, .display]) var testSetNeedsDisplayAndLayout: CGFloat = 0.0
52 |
53 | @SetNeeds(.layout, .display) var testSetNeedsDisplayAndLayout2: CGFloat = 0.0
54 |
55 | var layoutWasInvalidated: Bool = false
56 |
57 | var displayWasInvalidated: Bool = false
58 |
59 | override func setNeedsLayout() {
60 | super.setNeedsLayout()
61 |
62 | self.layoutWasInvalidated = true
63 | }
64 |
65 | override func setNeedsDisplay() {
66 | super.setNeedsDisplay()
67 |
68 | self.displayWasInvalidated = true
69 | }
70 |
71 | override func layoutSubviews() {
72 | super.layoutSubviews()
73 |
74 | self.layoutWasInvalidated = false
75 | }
76 |
77 | }
78 | #endif
79 |
80 | final class SetNeedsDisplayTests: XCTestCase {
81 |
82 | func testLayoutInvalidation() {
83 | let view = TestView(frame: .zero)
84 |
85 | view.layoutWasInvalidated = false
86 |
87 | view.testSetNeedsLayout = 50.0
88 |
89 | XCTAssertTrue(view.layoutWasInvalidated)
90 | }
91 |
92 | func testDisplayInvalidation() {
93 | let view = TestView(frame: .zero)
94 |
95 | view.displayWasInvalidated = false
96 |
97 | view.testSetNeedsDisplay = 50.0
98 |
99 | XCTAssertTrue(view.displayWasInvalidated)
100 | }
101 |
102 | func testDisplayAndLayoutInvalidation() {
103 | let view = TestView(frame: .zero)
104 |
105 | view.layoutWasInvalidated = false
106 | view.displayWasInvalidated = false
107 |
108 | view.testSetNeedsDisplayAndLayout = 50.0
109 |
110 | XCTAssertTrue(view.displayWasInvalidated && view.layoutWasInvalidated)
111 |
112 | view.layoutWasInvalidated = false
113 | view.displayWasInvalidated = false
114 |
115 | view.testSetNeedsDisplayAndLayout2 = 50.0
116 |
117 | XCTAssertTrue(view.displayWasInvalidated && view.layoutWasInvalidated)
118 | }
119 |
120 | static var allTests = [
121 | ("testLayoutInvalidation", testLayoutInvalidation),
122 | ("testDisplayInvalidation", testDisplayInvalidation),
123 | ("testDisplayAndLayoutInvalidation", testDisplayAndLayoutInvalidation),
124 | ]
125 | }
126 |
--------------------------------------------------------------------------------
/Tests/SetNeedsDisplayTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(SetNeedsDisplayTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------