diff --git a/CHANGELOG.md b/CHANGELOG.md index b478488f..f9c414dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Changelog ## master +- Added: `@Weak` property wrapper (#341) + ## [0.11.1] - Fixed: `@_spi` errors (#339) diff --git a/README.md b/README.md index 1f3a280c..f11c0115 100644 --- a/README.md +++ b/README.md @@ -199,14 +199,19 @@ TextField("Text Field", text: <#Binding#>) } ``` -Implement your own selector ---------------------------- +Advanced usage +-------------- + +### Implement your own introspectable type **Missing an element?** Please [create an issue](https://github.com/timbersoftware/SwiftUI-Introspect/issues). -In case SwiftUIIntrospect doesn't support the SwiftUI element that you're looking for, you can implement your own selector. For example, to introspect a `TextField`: +In case SwiftUIIntrospect (unlikely) doesn't support the SwiftUI element that you're looking for, you can implement your own introspectable type. + +For example, here's how the library implements the introspectable `TextField` type: ```swift +import SwiftUI @_spi(Advanced) import SwiftUIIntrospect public struct TextFieldType: IntrospectableViewType {} @@ -246,14 +251,48 @@ extension macOSViewVersion { #endif ``` -Releasing ---------- +### Introspect on future platform versions + +By default, introspection applies per specific platform version. This is a sensible default for maximum predictability in regularly maintained codebases, but it's not always a good fit for e.g. library developers who may want to cover as many future platform versions as possible in order to provide the best chance for long-term future functionality of their library without regular maintenance. + +For such cases, SwiftUI Introspect offers range-based platform version predicates behind the Advanced SPI: + +```swift +import SwiftUI +@_spi(Advanced) import SwiftUIIntrospect + +struct ContentView: View { + var body: some View { + ScrollView { + // ... + } + .introspect(.scrollView, on: .iOS(.v13...)) { scrollView in + // ... + } + } +} +``` + +Bear in mind this should be used cautiosly, and with full knowledge that any future OS version might break the expected introspection types unless explicitly available. For instance, if in the example above hypothetically iOS 18 stops using UIScrollView under the hood, the customization closure will never be called on said platform. + +### Keep instances outside the customize closure + +Sometimes, you might need to keep your introspected instance around for longer than the customization closure lifetime. In such cases, `@State` is not a good option because it produces retain cycles. Instead, SwiftUI Introspect offers a `@Weak` property wrapper behind the Advanced SPI: + +```swift +import SwiftUI +@_spi(Advanced) import SwiftUIIntrospect -1. Update changelog with new version -2. PR as 'Bump to X.Y.Z' and merge it -3. Tag new version: +struct ContentView: View { + @Weak var scrollView: UIScrollView? - ```sh - $ git tag X.Y.Z - $ git push origin --tags - ``` + var body: some View { + ScrollView { + // ... + } + .introspect(.scrollView, on: .iOS(.v13, .v14, .v15, .v16, .v17)) { scrollView in + self.scrollView = scrollView + } + } +} +``` diff --git a/Sources/Weak.swift b/Sources/Weak.swift new file mode 100644 index 00000000..f8ec5773 --- /dev/null +++ b/Sources/Weak.swift @@ -0,0 +1,14 @@ +@_spi(Advanced) +@propertyWrapper +public final class Weak { + private weak var _wrappedValue: T? + + public var wrappedValue: T? { + get { _wrappedValue } + set { _wrappedValue = newValue } + } + + public init(wrappedValue: T? = nil) { + self._wrappedValue = wrappedValue + } +} diff --git a/Tests/Tests.xcodeproj/project.pbxproj b/Tests/Tests.xcodeproj/project.pbxproj index 03bd41c5..1924c353 100644 --- a/Tests/Tests.xcodeproj/project.pbxproj +++ b/Tests/Tests.xcodeproj/project.pbxproj @@ -104,6 +104,12 @@ D58D83462A66C5EF00A203BE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D58D83452A66C5EF00A203BE /* Assets.xcassets */; }; D58D83492A66C5EF00A203BE /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D58D83482A66C5EF00A203BE /* Preview Assets.xcassets */; }; D58D83502A66C67A00A203BE /* TestCases.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58D834F2A66C67A00A203BE /* TestCases.swift */; }; + D591D1122A9CC2FF00AE05E8 /* WeakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D591D1112A9CC2FF00AE05E8 /* WeakTests.swift */; }; + D591D1132A9CC2FF00AE05E8 /* WeakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D591D1112A9CC2FF00AE05E8 /* WeakTests.swift */; }; + D591D1142A9CC30B00AE05E8 /* PageControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F26E012A561130001209E6 /* PageControlTests.swift */; }; + D591D1152A9CC30B00AE05E8 /* MapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF56E2A502EF000CAFFB6 /* MapTests.swift */; }; + D591D1162A9CC30B00AE05E8 /* ViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F26E032A56E74B001209E6 /* ViewControllerTests.swift */; }; + D591D1172A9CC30B00AE05E8 /* SecureFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57E66F92A6956EB0092F43E /* SecureFieldTests.swift */; }; D5983E7D2A66FD3F00C50953 /* TestCases.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58D834F2A66C67A00A203BE /* TestCases.swift */; }; D5AAF56F2A502EF000CAFFB6 /* MapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF56E2A502EF000CAFFB6 /* MapTests.swift */; }; D5AD0D912A114B98003D8DEC /* TextFieldWithVerticalAxisTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AD0D902A114B98003D8DEC /* TextFieldWithVerticalAxisTests.swift */; }; @@ -209,6 +215,7 @@ D58D83452A66C5EF00A203BE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D58D83482A66C5EF00A203BE /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; D58D834F2A66C67A00A203BE /* TestCases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestCases.swift; sourceTree = ""; }; + D591D1112A9CC2FF00AE05E8 /* WeakTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakTests.swift; sourceTree = ""; }; D5AAF56E2A502EF000CAFFB6 /* MapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTests.swift; sourceTree = ""; }; D5AD0D902A114B98003D8DEC /* TextFieldWithVerticalAxisTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldWithVerticalAxisTests.swift; sourceTree = ""; }; D5ADFACB2A4A22AE009494FD /* SheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetTests.swift; sourceTree = ""; }; @@ -446,6 +453,7 @@ children = ( D5B67B852A0D3193007D5D9B /* ViewTypes */, D5F0BE6729C0DC4900AD95AB /* PlatformVersionTests.swift */, + D591D1112A9CC2FF00AE05E8 /* WeakTests.swift */, D58CE15729C621DD0081BFB0 /* TestUtils.swift */, ); path = Tests; @@ -697,10 +705,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D591D1172A9CC30B00AE05E8 /* SecureFieldTests.swift in Sources */, D5ADFAD42A4A4653009494FD /* FullScreenCoverTests.swift in Sources */, D50E2F5E2A2B9F6600BAFB03 /* ScrollViewTests.swift in Sources */, D50E2F5F2A2B9F6600BAFB03 /* NavigationStackTests.swift in Sources */, D50E2F602A2B9F6600BAFB03 /* DatePickerWithGraphicalStyleTests.swift in Sources */, + D591D1152A9CC30B00AE05E8 /* MapTests.swift in Sources */, + D591D1132A9CC2FF00AE05E8 /* WeakTests.swift in Sources */, D50E2F612A2B9F6600BAFB03 /* DatePickerWithCompactFieldStyleTests.swift in Sources */, D50E2F622A2B9F6600BAFB03 /* ToggleWithCheckboxStyleTests.swift in Sources */, D50E2F632A2B9F6600BAFB03 /* TabViewTests.swift in Sources */, @@ -734,6 +745,7 @@ D50E2F7B2A2B9F6600BAFB03 /* PlatformVersionTests.swift in Sources */, D50E2F7C2A2B9F6600BAFB03 /* TestUtils.swift in Sources */, D50E2F7D2A2B9F6600BAFB03 /* PickerWithSegmentedStyleTests.swift in Sources */, + D591D1142A9CC30B00AE05E8 /* PageControlTests.swift in Sources */, D50E2F7E2A2B9F6600BAFB03 /* TabViewWithPageStyleTests.swift in Sources */, D50E2F7F2A2B9F6600BAFB03 /* DatePickerWithFieldStyleTests.swift in Sources */, D50E2F802A2B9F6600BAFB03 /* TableTests.swift in Sources */, @@ -742,6 +754,7 @@ D5ADFAD62A4A4653009494FD /* VideoPlayerTests.swift in Sources */, D50E2F832A2B9F6600BAFB03 /* ListCellTests.swift in Sources */, D50E2F842A2B9F6600BAFB03 /* SearchFieldTests.swift in Sources */, + D591D1162A9CC30B00AE05E8 /* ViewControllerTests.swift in Sources */, D50E2F852A2B9F6600BAFB03 /* ViewTests.swift in Sources */, D50E2F862A2B9F6600BAFB03 /* ListWithGroupedStyleTests.swift in Sources */, D50E2F872A2B9F6600BAFB03 /* ProgressViewWithCircularStyleTests.swift in Sources */, @@ -804,6 +817,7 @@ D575069E2A27F80E00A628E4 /* ProgressViewWithLinearStyleTests.swift in Sources */, D57506862A27CA4100A628E4 /* ListWithBorderedStyleTests.swift in Sources */, D5F8D5ED2A1E7B490054E9AB /* NavigationViewWithStackStyleTests.swift in Sources */, + D591D1122A9CC2FF00AE05E8 /* WeakTests.swift in Sources */, D57506942A27EED200A628E4 /* DatePickerWithStepperFieldStyleTests.swift in Sources */, D5AD0D912A114B98003D8DEC /* TextFieldWithVerticalAxisTests.swift in Sources */, D58119D02A23A62C0081F853 /* SliderTests.swift in Sources */, diff --git a/Tests/Tests/WeakTests.swift b/Tests/Tests/WeakTests.swift new file mode 100644 index 00000000..a626b29d --- /dev/null +++ b/Tests/Tests/WeakTests.swift @@ -0,0 +1,56 @@ +@_spi(Advanced) import SwiftUIIntrospect +import XCTest + +final class WeakTests: XCTestCase { + final class Foo {} + + var strongFoo: Foo? = Foo() + + func testInit_nil() { + @Weak var weakFoo: Foo? + XCTAssertNil(weakFoo) + } + + func testInit_nonNil() { + @Weak var weakFoo: Foo? = strongFoo + XCTAssertIdentical(weakFoo, strongFoo) + } + + func testAssignment_nilToNil() { + @Weak var weakFoo: Foo? + weakFoo = nil + XCTAssertNil(weakFoo) + } + + func testAssignment_nilToNonNil() { + @Weak var weakFoo: Foo? + let otherFoo = Foo() + weakFoo = otherFoo + XCTAssertIdentical(weakFoo, otherFoo) + } + + func testAssignment_nonNilToNil() { + @Weak var weakFoo: Foo? = strongFoo + weakFoo = nil + XCTAssertNil(weakFoo) + } + + func testAssignment_nonNilToNonNil() { + @Weak var weakFoo: Foo? = strongFoo + let otherFoo = Foo() + weakFoo = otherFoo + XCTAssertIdentical(weakFoo, otherFoo) + } + + func testIndirectAssignment_nonNilToNil() { + @Weak var weakFoo: Foo? = strongFoo + strongFoo = nil + XCTAssertNil(weakFoo) + } + + func testIndirectAssignment_nonNilToNonNil() { + @Weak var weakFoo: Foo? = strongFoo + strongFoo = Foo() + XCTAssertNil(weakFoo) + } +}