├── .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 | ![Tests](https://github.com/b3ll/SetNeedsDisplay/workflows/Tests/badge.svg) 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 | --------------------------------------------------------------------------------