diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index c0af03a..10c8c06 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -1,7 +1,6 @@ name: Build Examples -on: - pull_request +on: pull_request jobs: build-examples: @@ -12,14 +11,19 @@ jobs: cancel-in-progress: true strategy: matrix: - scheme: ['Example (iOS)', 'UIKitApp'] + scheme: ["Example (iOS)", "UIKitApp"] steps: - uses: actions/checkout@v3 + + - name: Selecct Xcode 14.1 + run: sudo xcode-select -s '/Applications/Xcode_14.1.app/Contents/Developer' + - name: SwiftPM cache uses: actions/cache@v3 with: path: SourcePackages key: ${{ runner.os }}-swiftpm-${{ hashFiles('**/Package.resolved') }} + - name: Build run: xcodebuild -project Example/Example.xcodeproj -scheme "${{ matrix.scheme }}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 13,OS=latest' -clonedSourcePackagesDirPath SourcePackages diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 19dc264..bb4e6ff 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ 70A4A52F28251AB3007F2033 /* iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOS.entitlements; sourceTree = ""; }; 70B1D331282C870A000D0386 /* SwiftUI-Common */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "SwiftUI-Common"; path = "../../SwiftUI-Common"; sourceTree = ""; }; 70F03A902826266600D86CAB /* BrowserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserViewController.swift; sourceTree = ""; }; + 70FB86382920D1C500F19842 /* UserDefaultsBrowser */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = UserDefaultsBrowser; path = ..; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -131,6 +132,7 @@ isa = PBXGroup; children = ( 70A4A52E28251A19007F2033 /* UserDefaults-Browser */, + 70FB86382920D1C500F19842 /* UserDefaultsBrowser */, ); name = Packages; sourceTree = ""; diff --git a/Package.swift b/Package.swift index 7c39473..21bb207 100644 --- a/Package.swift +++ b/Package.swift @@ -14,13 +14,11 @@ let package = Package( dependencies: [ .package(url: "https://github.com/YusukeHosonuma/SwiftPrettyPrint.git", from: "1.3.0"), .package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "0.8.0"), - .package(url: "https://github.com/YusukeHosonuma/SwiftUI-Common.git", from: "1.0.0"), ], targets: [ .target(name: "UserDefaultsBrowser", dependencies: [ "SwiftPrettyPrint", .product(name: "CasePaths", package: "swift-case-paths"), - .product(name: "SwiftUICommon", package: "SwiftUI-Common"), ]), .testTarget(name: "UserDefaultsBrowserTests", dependencies: ["UserDefaultsBrowser"]), ] diff --git a/Sources/UserDefaultsBrowser/SwiftUICommon/Binding+.swift b/Sources/UserDefaultsBrowser/SwiftUICommon/Binding+.swift new file mode 100644 index 0000000..590d23c --- /dev/null +++ b/Sources/UserDefaultsBrowser/SwiftUICommon/Binding+.swift @@ -0,0 +1,95 @@ +import CasePaths +import SwiftUI + +public extension Binding { + // + // `Binding` -> `Binding` + // + func map(get: @escaping (Value) -> NewValue, set: @escaping (NewValue) -> Value) -> Binding { + .init( + get: { get(wrappedValue) }, + set: { wrappedValue = set($0) } + ) + } + + // + // 🌱 Experimental. + // + // `Binding` -> `Binding` (`set` can be return `nil`) + // + // func map(get: @escaping (Value) -> NewValue, set: @escaping (NewValue) -> Value?) -> Binding { + // .init( + // get: { get(wrappedValue) }, + // set: { + // if let value = set($0) { + // wrappedValue = value + // } + // } + // ) + // } + // + // + // `Binding` -> `Binding` + // + func optional() -> Binding { + .init( + get: { self.wrappedValue }, + set: { + if let value = $0 { + self.wrappedValue = value + } + } + ) + } + + // + // `Binding` -> `Binding` + // + func isPresent() -> Binding where Value == Wrapped? { + .init( + get: { self.wrappedValue != nil }, + set: { + if $0 == false { + self.wrappedValue = nil + } + } + ) + } + + // + // `Binding` -> `Binding?` + // + func wrapped() -> Binding? where Value == Wrapped? { + if let value = wrappedValue { + return .init( + get: { value }, + set: { wrappedValue = $0 } + ) + } else { + return nil + } + } + + // + // `Binding` -> `Binding?` + // + func `case`(_ path: CasePath) -> Binding? { + if let value = path.extract(from: wrappedValue) { + return .init( + get: { value }, + set: { wrappedValue = path.embed($0) } + ) + } else { + return nil + } + } +} + +public extension Binding where Value == Bool { + func inverted() -> Binding { + .init( + get: { !wrappedValue }, + set: { wrappedValue = !$0 } + ) + } +} diff --git a/Sources/UserDefaultsBrowser/SwiftUICommon/ResizableImage.swift b/Sources/UserDefaultsBrowser/SwiftUICommon/ResizableImage.swift new file mode 100644 index 0000000..b79b53c --- /dev/null +++ b/Sources/UserDefaultsBrowser/SwiftUICommon/ResizableImage.swift @@ -0,0 +1,91 @@ +import SwiftUI + +public extension ResizableImage { + init(_ name: String, contentMode: ContentMode) { + self.init(image: Image(name), contentMode: contentMode) + } + + init(systemName: String, contentMode: ContentMode) { + self.init(image: Image(systemName: systemName), contentMode: contentMode) + } +} + +#if canImport(UIKit) +import UIKit + +public extension ResizableImage { + init(uiImage: UIImage, contentMode: ContentMode) { + self.init(image: Image(uiImage: uiImage), contentMode: contentMode) + } +} +#endif + +#if canImport(AppKit) +import AppKit + +public extension ResizableImage { + init(nsImage: NSImage, contentMode: ContentMode) { + self.init(image: Image(nsImage: nsImage), contentMode: contentMode) + } +} +#endif + +public struct ResizableImage: View { + private let image: Image + private let contentMode: ContentMode + + @State private var imageSize: CGSize? + + public var body: some View { + GeometryReader { geometry in + Group { + if let size = imageSize { + image + .resizable() + .aspectRatio(contentMode: contentMode) + .frame(size: size) + } else { + // 💡 1st time only + image + .onChangeSize { + imageSize = Self.calculateImageSize( + bounds: geometry.size, + originalSize: $0, + contentMode: contentMode + ) + } + } + } + } + .frame(size: imageSize) + } + + static func calculateImageSize(bounds: CGSize, originalSize size: CGSize, contentMode: ContentMode) -> CGSize { + if size.width < bounds.width { + return size + } else { + if (contentMode == .fit && size.height < size.width) || + (contentMode == .fill && size.width < size.height) + { + // + // Calculated from `width`. + // + return .init( + width: bounds.width, + height: size.height * (bounds.width / size.width) + ) + } else { + // + // Calculated from `height`. + // + // ⚠️ Can't get correct size from `bounds.height` when used in `ScrollView` and others. + // + let ratio = (size.width / size.height) + return .init( + width: bounds.width * ratio, + height: size.height * (bounds.width / size.width) * ratio + ) + } + } + } +} diff --git a/Sources/UserDefaultsBrowser/SwiftUICommon/View+.swift b/Sources/UserDefaultsBrowser/SwiftUICommon/View+.swift new file mode 100644 index 0000000..a558ea5 --- /dev/null +++ b/Sources/UserDefaultsBrowser/SwiftUICommon/View+.swift @@ -0,0 +1,45 @@ +import SwiftUI + +extension View { + func enabled(_ enabled: Bool) -> some View { + disabled(enabled == false) + } + + func extend(@ViewBuilder transform: (Self) -> Content) -> some View { + transform(self) + } + + func frame(size: CGSize?) -> some View { + frame(width: size?.width, height: size?.height) + } + + func onChangeSize(perform: @escaping (CGSize) -> Void) -> some View { + sizePreference() + .onChangeSizePreference(perform: perform) + } + + func sizePreference() -> some View { + background( + GeometryReader { local in + Color.clear + .preference(key: SizeKey.self, value: local.size) + } + ) + } + + func onChangeSizePreference(perform: @escaping (CGSize) -> Void) -> some View { + onPreferenceChange(SizeKey.self) { size in + if let size = size { + perform(size) + } + } + } +} + +private struct SizeKey: PreferenceKey { + static var defaultValue: CGSize? + + static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) { + value = nextValue() + } +} diff --git a/Sources/UserDefaultsBrowser/UserDefaultsBrowserContainer.swift b/Sources/UserDefaultsBrowser/UserDefaultsBrowserContainer.swift index 5f3d8fa..12b89b6 100644 --- a/Sources/UserDefaultsBrowser/UserDefaultsBrowserContainer.swift +++ b/Sources/UserDefaultsBrowser/UserDefaultsBrowserContainer.swift @@ -6,7 +6,6 @@ // import SwiftUI -import SwiftUICommon public struct UserDefaultsBrowserContainer: View { private let suiteNames: [String] diff --git a/Sources/UserDefaultsBrowser/View/RowView.swift b/Sources/UserDefaultsBrowser/View/RowView.swift index c832d94..4df787e 100644 --- a/Sources/UserDefaultsBrowser/View/RowView.swift +++ b/Sources/UserDefaultsBrowser/View/RowView.swift @@ -7,7 +7,6 @@ import SwiftPrettyPrint import SwiftUI -import SwiftUICommon private enum Value { case text(String)