diff --git a/Example/SwiftUIKitExample/SwiftUIwithUIKitView.swift b/Example/SwiftUIKitExample/SwiftUIwithUIKitView.swift index 0e438d5..b2815a1 100644 --- a/Example/SwiftUIKitExample/SwiftUIwithUIKitView.swift +++ b/Example/SwiftUIKitExample/SwiftUIwithUIKitView.swift @@ -9,14 +9,22 @@ import SwiftUI import SwiftUIKitView struct SwiftUIwithUIKitView: View { + @State var integer: Int = 0 + var body: some View { NavigationView { - UIKitView() // <- This is a `UIKit` view. - .swiftUIView(layout: .intrinsic) // <- This is a SwiftUI `View`. - .set(\.title, to: "Hello, UIKit!") - .set(\.backgroundColor, to: UIColor(named: "swiftlee_orange")) - .fixedSize() - .navigationTitle("Use UIKit in SwiftUI") + VStack { + // Use UIKit inside SwiftUI like this: + UIViewContainer(UIKitView(), layout: .intrinsic) + .set(\.title, to: "Hello, UIKit \(integer)!") + .set(\.backgroundColor, to: UIColor(named: "swiftlee_orange")) + .fixedSize() + .navigationTitle("Use UIKit in SwiftUI") + + Button("RANDOMIZED: \(integer)") { + integer = Int.random(in: 0..<300) + } + } } } } @@ -38,4 +46,3 @@ struct UILabelExample_Preview: PreviewProvider { .previewDisplayName("UILabel Preview Example") } } - diff --git a/Example/SwiftUIKitExample/UIKitView.swift b/Example/SwiftUIKitExample/UIKitView.swift index b435077..637d2ae 100644 --- a/Example/SwiftUIKitExample/UIKitView.swift +++ b/Example/SwiftUIKitExample/UIKitView.swift @@ -48,6 +48,8 @@ final class UIKitView: UIView { override init(frame: CGRect) { super.init(frame: frame) setupView() + + print("INIT") } required init?(coder: NSCoder) { diff --git a/Package.swift b/Package.swift index a2a06c1..ffaa154 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.5 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,24 +6,17 @@ import PackageDescription let package = Package( name: "SwiftUIKitView", platforms: [ - .iOS(.v13), + .iOS(.v14), .macOS(.v10_15), - .tvOS(.v13), + .tvOS(.v14), .watchOS(.v6) ], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "SwiftUIKitView", targets: ["SwiftUIKitView"]), ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "SwiftUIKitView", dependencies: []), diff --git a/README.md b/README.md index 36b8008..6b027a8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # SwiftUIKitView -![Swift Version](https://img.shields.io/badge/Swift-5.3-F16D39.svg?style=flat) ![Dependency frameworks](https://img.shields.io/badge/Supports-_Swift_Package_Manager-F16D39.svg?style=flat) [![Twitter](https://img.shields.io/badge/twitter-@Twannl-blue.svg?style=flat)](https://twitter.com/twannl) +![Swift Version](https://img.shields.io/badge/Swift-5.5-F16D39.svg?style=flat) ![Dependency frameworks](https://img.shields.io/badge/Supports-_Swift_Package_Manager-F16D39.svg?style=flat) [![Twitter](https://img.shields.io/badge/twitter-@Twannl-blue.svg?style=flat)](https://twitter.com/twannl) Easily use UIKit views in SwiftUI. @@ -11,7 +11,14 @@ You can read more about [Getting started with UIKit in SwiftUI and visa versa](h ## Examples -Using a `UIKit` view directly in SwiftUI: +### Using SwiftUIKitView in Production Code +Using a `UIKit` view directly in SwiftUI for production code requires you to use: + +```swift +UIViewContainer(, layout: ) +``` + +This is to prevent a UIKit view from being redrawn on every SwiftUI view redraw. ```swift import SwiftUI @@ -20,8 +27,7 @@ import SwiftUIKitView struct SwiftUIwithUIKitView: View { var body: some View { NavigationView { - UILabel() // <- This can be any `UIKit` view. - .swiftUIView(layout: .intrinsic) // <- This is returning a SwiftUI `View`. + UIViewContainer(UILabel(), layout: .intrinsic) // <- This can be any `UIKit` view. .set(\.text, to: "Hello, UIKit!") // <- Use key paths for updates. .set(\.backgroundColor, to: UIColor(named: "swiftlee_orange")) .fixedSize() @@ -31,7 +37,16 @@ struct SwiftUIwithUIKitView: View { } ``` -Creating a preview provider for a `UIView`: +### Using `SwiftUIKitView` in Previews +Performance in Previews is less important, it's being redrawn either way. +Therefore, you can use of the more convenient `swiftUIView()` modifier: + +```swift +UILabel() // <- This is a `UIKit` view. + .swiftUIView(layout: .intrinsic) // <- This is a SwiftUI `View`. +``` + +Creating a preview provider for a `UIView` looks as follows: ```swift import SwiftUI @@ -96,7 +111,7 @@ Once you have your Swift package set up, adding the SDK as a dependency is as ea ```swift dependencies: [ - .package(url: "https://github.com/AvdLee/SwiftUIKitView.git", .upToNextMajor(from: "1.0.0")) + .package(url: "https://github.com/AvdLee/SwiftUIKitView.git", .upToNextMajor(from: "2.0.0")) ] ``` diff --git a/Sources/SwiftUIKitView/IntrinsicContentView.swift b/Sources/SwiftUIKitView/IntrinsicContentView.swift new file mode 100644 index 0000000..c3db762 --- /dev/null +++ b/Sources/SwiftUIKitView/IntrinsicContentView.swift @@ -0,0 +1,69 @@ +// +// IntrinsicContentView.swift +// +// +// Created by Antoine van der Lee on 23/07/2022. +// + +import Foundation +import UIKit + +public final class IntrinsicContentView: UIView { + let contentView: ContentView + let layout: Layout + + init(contentView: ContentView, layout: Layout) { + self.contentView = contentView + self.layout = layout + + super.init(frame: .zero) + backgroundColor = .clear + addSubview(contentView) + clipsToBounds = true + } + + @available(*, unavailable) required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var contentSize: CGSize = .zero { + didSet { + invalidateIntrinsicContentSize() + } + } + + public override var intrinsicContentSize: CGSize { + switch layout { + case .intrinsic: + return contentSize + case .fixedWidth(let width): + return .init(width: width, height: contentSize.height) + case .fixed(let size): + return size + } + } + + public func updateContentSize() { + switch layout { + case .fixedWidth(let width): + // Set the frame of the cell, so that the layout can be updated. + var newFrame = contentView.frame + newFrame.size = CGSize(width: width, height: UIView.layoutFittingExpandedSize.height) + contentView.frame = newFrame + + // Make sure the contents of the cell have the correct layout. + contentView.setNeedsLayout() + contentView.layoutIfNeeded() + + // Get the size of the cell + let computedSize = contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + + // Apple: "Only consider the height for cells, because the contentView isn't anchored correctly sometimes." We use ceil to make sure we get rounded numbers and no half pixels. + contentSize = CGSize(width: width, height: ceil(computedSize.height)) + case .fixed(let size): + contentSize = size + case .intrinsic: + contentSize = contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + } + } +} diff --git a/Sources/SwiftUIKitView/ModifiedUIViewContainer.swift b/Sources/SwiftUIKitView/ModifiedUIViewContainer.swift new file mode 100644 index 0000000..e3532fd --- /dev/null +++ b/Sources/SwiftUIKitView/ModifiedUIViewContainer.swift @@ -0,0 +1,39 @@ +// +// ModifiedUIViewContainer.swift +// +// +// Created by Antoine van der Lee on 23/07/2022. +// + +import Foundation +import SwiftUI +import UIKit + +public struct ModifiedUIViewContainer: UIViewContaining where ChildContainer.Child == Child { + + let child: ChildContainer + let keyPath: ReferenceWritableKeyPath + let value: Value + + public func makeCoordinator() -> UIViewContainingCoordinator { + child.makeCoordinator() as! UIViewContainingCoordinator + } + + public func makeUIView(context: Context) -> IntrinsicContentView { + context.coordinator.createView() + } + + public func updateUIView(_ uiView: IntrinsicContentView, context: Context) { + update(uiView.contentView, coordinator: context.coordinator, updateContentSize: true) + } + + public func update(_ uiView: Child, coordinator: UIViewContainingCoordinator, updateContentSize: Bool) { + uiView[keyPath: keyPath] = value + child.update(uiView, coordinator: coordinator, updateContentSize: false) + + if updateContentSize { + coordinator.view?.updateContentSize() + } + } +} + diff --git a/Sources/SwiftUIKitView/SwiftUIViewConvertable.swift b/Sources/SwiftUIKitView/SwiftUIViewConvertable.swift index 152b47f..179d859 100644 --- a/Sources/SwiftUIKitView/SwiftUIViewConvertable.swift +++ b/Sources/SwiftUIKitView/SwiftUIViewConvertable.swift @@ -12,7 +12,7 @@ import UIKit @available(iOS 13.0, *) public protocol SwiftUIViewConvertable { associatedtype View: UIView - func swiftUIView(layout: UIViewContainer.Layout) -> UIViewContainer + func swiftUIView(layout: Layout) -> UIViewContainer } /// Add default protocol comformance for `UIView` instances. @@ -20,7 +20,11 @@ extension UIView: SwiftUIViewConvertable {} @available(iOS 13.0, *) public extension SwiftUIViewConvertable where Self: UIView { - func swiftUIView(layout: UIViewContainer.Layout) -> UIViewContainer { + func swiftUIView(layout: Layout) -> UIViewContainer { + assert( + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1", + "This method is designed to use in previews only and is not performant for production code. Use `UIViewContainer(, layout: layout)` instead." + ) return UIViewContainer(self, layout: layout) } } diff --git a/Sources/SwiftUIKitView/UIViewContainer.swift b/Sources/SwiftUIKitView/UIViewContainer.swift index cb9b47c..8693002 100644 --- a/Sources/SwiftUIKitView/UIViewContainer.swift +++ b/Sources/SwiftUIKitView/UIViewContainer.swift @@ -11,83 +11,18 @@ import SwiftUI /// A container for UIKit `UIView` elements. Conforms to the `UIViewRepresentable` protocol to allow conversion into SwiftUI `View`s. @available(iOS 13.0, *) -public struct UIViewContainer: Identifiable { - - public var id: UIView { view } +public struct UIViewContainer { - /// The type of Layout to apply to the SwiftUI `View`. - public enum Layout { + let viewCreator: () -> Child + let layout: Layout - /// Uses the size returned by .`systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)`. - case intrinsic - - /// Uses an intrinsic height combined with a fixed width. - case fixedWidth(width: CGFloat) - - /// A fixed width and height is used. - case fixed(size: CGSize) - } - - private let view: Child - private let layout: Layout - - /// - Returns: The `CGSize` to apply to the view. - private var size: CGSize { - switch layout { - case .fixedWidth(let width): - // Set the frame of the cell, so that the layout can be updated. - var newFrame = view.frame - newFrame.size = CGSize(width: width, height: UIView.layoutFittingExpandedSize.height) - view.frame = newFrame - - // Make sure the contents of the cell have the correct layout. - view.setNeedsLayout() - view.layoutIfNeeded() - - // Get the size of the cell - let computedSize = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) - - // Apple: "Only consider the height for cells, because the contentView isn't anchored correctly sometimes." We use ceil to make sure we get rounded numbers and no half pixels. - return CGSize(width: width, height: ceil(computedSize.height)) - case .fixed(let size): - return size - case .intrinsic: - return view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) - } - } - /// Initializes a `UIViewContainer` /// - Parameters: /// - view: `UIView` being previewed /// - layout: The layout to apply on the `UIView`. Defaults to `intrinsic`. - public init(_ view: @autoclosure () -> Child, layout: Layout = .intrinsic) { - self.view = view() + public init(_ viewCreator: @escaping @autoclosure () -> Child, layout: Layout = .intrinsic) { + self.viewCreator = viewCreator self.layout = layout - - switch layout { - case .intrinsic: - return - case .fixed(let size): - self.view.widthAnchor.constraint(equalToConstant: size.width).isActive = true - self.view.heightAnchor.constraint(equalToConstant: size.height).isActive = true - case .fixedWidth(let width): - self.view.widthAnchor.constraint(equalToConstant: width).isActive = true - } - } - - /// Applies the correct size to the SwiftUI `View` container. - /// - Returns: A `View` with the correct size applied. - public func fixedSize() -> some View { - let size = self.size - return frame(width: size.width, height: size.height, alignment: .topLeading) - } - - /// Creates a preview of the `UIViewContainer` with the right size applied. - /// - Returns: A preview of the container. - public func preview(displayName: String? = nil) -> some View { - return fixedSize() - .previewLayout(.sizeThatFits) - .previewDisplayName(displayName) } } @@ -95,20 +30,24 @@ public struct UIViewContainer: Identifiable { @available(iOS 13, *) extension UIViewContainer: UIViewRepresentable { + public func makeCoordinator() -> UIViewContainingCoordinator { + // Create an instance of Coordinator + Coordinator(viewCreator, layout: layout) + } - public func makeUIView(context: Context) -> UIView { - return view + public func makeUIView(context: Context) -> IntrinsicContentView { + context.coordinator.createView() } - public func updateUIView(_ view: UIView, context: Context) {} + public func updateUIView(_ view: IntrinsicContentView, context: Context) { + update(view.contentView, coordinator: context.coordinator, updateContentSize: true) + + } } -@available(iOS 13.0, *) -extension UIViewContainer: KeyPathReferenceWritable { - public typealias T = Child - - public func set(_ keyPath: ReferenceWritableKeyPath, to value: Value) -> Self { - view[keyPath: keyPath] = value - return self +extension UIViewContainer: UIViewContaining { + public func update(_ uiView: Child, coordinator: UIViewContainingCoordinator, updateContentSize: Bool) { + guard updateContentSize else { return } + coordinator.view?.updateContentSize() } } diff --git a/Sources/SwiftUIKitView/UIViewContaining.swift b/Sources/SwiftUIKitView/UIViewContaining.swift new file mode 100644 index 0000000..c9071bf --- /dev/null +++ b/Sources/SwiftUIKitView/UIViewContaining.swift @@ -0,0 +1,32 @@ +// +// UIViewContaining.swift +// +// +// Created by Antoine van der Lee on 23/07/2022. +// + +import Foundation +import UIKit +import SwiftUI + +public protocol UIViewContaining: UIViewRepresentable { + associatedtype Child: UIView + func set(_ keyPath: ReferenceWritableKeyPath, to value: Value) -> ModifiedUIViewContainer + func update(_ uiView: Child, coordinator: UIViewContainingCoordinator, updateContentSize: Bool) +} + +extension UIViewContaining { + public func set(_ keyPath: ReferenceWritableKeyPath, to value: Value) -> ModifiedUIViewContainer { + ModifiedUIViewContainer(child: self, keyPath: keyPath, value: value) + } +} + +public extension View { + /// Creates a preview of the `UIViewContainer` with the right size applied. + /// - Returns: A preview of the container. + func preview(displayName: String? = nil) -> some View { + return fixedSize() + .previewLayout(.sizeThatFits) + .previewDisplayName(displayName) + } +} diff --git a/Sources/SwiftUIKitView/UIViewContainingCoordinator.swift b/Sources/SwiftUIKitView/UIViewContainingCoordinator.swift new file mode 100644 index 0000000..cc75276 --- /dev/null +++ b/Sources/SwiftUIKitView/UIViewContainingCoordinator.swift @@ -0,0 +1,47 @@ +// +// UIViewContainingCoordinator.swift +// +// +// Created by Antoine van der Lee on 23/07/2022. +// + +import Foundation +import UIKit +import SwiftUI + +/// The type of Layout to apply to the SwiftUI `View`. +public enum Layout { + + /// Uses the size returned by .`systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)`. + case intrinsic + + /// Uses an intrinsic height combined with a fixed width. + case fixedWidth(width: CGFloat) + + /// A fixed width and height is used. + case fixed(size: CGSize) +} + +public class UIViewContainingCoordinator { + private(set) var view: IntrinsicContentView? + private var viewCreator: () -> Child + + var widthConstraint: NSLayoutConstraint? + var heightConstraint: NSLayoutConstraint? + private let layout: Layout + + init(_ viewCreator: @escaping () -> Child, layout: Layout) { + self.viewCreator = viewCreator + self.layout = layout + } + + func createView() -> IntrinsicContentView { + if let view = view { + return view + } else { + let contentView = IntrinsicContentView(contentView: viewCreator(), layout: layout) + view = contentView + return contentView + } + } +}