Skip to content

Commit

Permalink
Production ready code for UIKit in SwiftUI (#6)
Browse files Browse the repository at this point in the history
* First iteration

* Second iteration

* Iteration 3

* Start of iteration 4

* Create IntrinsicContentView

* Fixed content size

* Update readme, clean up code
  • Loading branch information
AvdLee authored Jul 23, 2022
1 parent 8f0239c commit 56f2a1f
Show file tree
Hide file tree
Showing 10 changed files with 252 additions and 105 deletions.
21 changes: 14 additions & 7 deletions Example/SwiftUIKitExample/SwiftUIwithUIKitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
}
Expand All @@ -38,4 +46,3 @@ struct UILabelExample_Preview: PreviewProvider {
.previewDisplayName("UILabel Preview Example")
}
}

2 changes: 2 additions & 0 deletions Example/SwiftUIKitExample/UIKitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ final class UIKitView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
setupView()

print("INIT")
}

required init?(coder: NSCoder) {
Expand Down
13 changes: 3 additions & 10 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,29 +1,22 @@
// 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

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: []),
Expand Down
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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(<YOUR UIKit View>, layout: <YOUR LAYOUT PREFERENCE>)
```

This is to prevent a UIKit view from being redrawn on every SwiftUI view redraw.

```swift
import SwiftUI
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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"))
]
```

Expand Down
69 changes: 69 additions & 0 deletions Sources/SwiftUIKitView/IntrinsicContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// IntrinsicContentView.swift
//
//
// Created by Antoine van der Lee on 23/07/2022.
//

import Foundation
import UIKit

public final class IntrinsicContentView<ContentView: UIView>: 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)
}
}
}
39 changes: 39 additions & 0 deletions Sources/SwiftUIKitView/ModifiedUIViewContainer.swift
Original file line number Diff line number Diff line change
@@ -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<ChildContainer: UIViewContaining, Child, Value>: UIViewContaining where ChildContainer.Child == Child {

let child: ChildContainer
let keyPath: ReferenceWritableKeyPath<Child, Value>
let value: Value

public func makeCoordinator() -> UIViewContainingCoordinator<Child> {
child.makeCoordinator() as! UIViewContainingCoordinator<Child>
}

public func makeUIView(context: Context) -> IntrinsicContentView<Child> {
context.coordinator.createView()
}

public func updateUIView(_ uiView: IntrinsicContentView<Child>, context: Context) {
update(uiView.contentView, coordinator: context.coordinator, updateContentSize: true)
}

public func update(_ uiView: Child, coordinator: UIViewContainingCoordinator<Child>, updateContentSize: Bool) {
uiView[keyPath: keyPath] = value
child.update(uiView, coordinator: coordinator, updateContentSize: false)

if updateContentSize {
coordinator.view?.updateContentSize()
}
}
}

8 changes: 6 additions & 2 deletions Sources/SwiftUIKitView/SwiftUIViewConvertable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ import UIKit
@available(iOS 13.0, *)
public protocol SwiftUIViewConvertable {
associatedtype View: UIView
func swiftUIView(layout: UIViewContainer<View>.Layout) -> UIViewContainer<View>
func swiftUIView(layout: Layout) -> UIViewContainer<View>
}

/// Add default protocol comformance for `UIView` instances.
extension UIView: SwiftUIViewConvertable {}

@available(iOS 13.0, *)
public extension SwiftUIViewConvertable where Self: UIView {
func swiftUIView(layout: UIViewContainer<Self>.Layout) -> UIViewContainer<Self> {
func swiftUIView(layout: Layout) -> UIViewContainer<Self> {
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(<YOUR VIEW>, layout: layout)` instead."
)
return UIViewContainer(self, layout: layout)
}
}
99 changes: 19 additions & 80 deletions Sources/SwiftUIKitView/UIViewContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,104 +11,43 @@ 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<Child: UIView>: Identifiable {

public var id: UIView { view }
public struct UIViewContainer<Child: UIView> {

/// 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)
}
}

// MARK: Preview + UIViewRepresentable

@available(iOS 13, *)
extension UIViewContainer: UIViewRepresentable {
public func makeCoordinator() -> UIViewContainingCoordinator<Child> {
// Create an instance of Coordinator
Coordinator(viewCreator, layout: layout)
}

public func makeUIView(context: Context) -> UIView {
return view
public func makeUIView(context: Context) -> IntrinsicContentView<Child> {
context.coordinator.createView()
}

public func updateUIView(_ view: UIView, context: Context) {}
public func updateUIView(_ view: IntrinsicContentView<Child>, context: Context) {
update(view.contentView, coordinator: context.coordinator, updateContentSize: true)

}
}

@available(iOS 13.0, *)
extension UIViewContainer: KeyPathReferenceWritable {
public typealias T = Child

public func set<Value>(_ keyPath: ReferenceWritableKeyPath<Child, Value>, to value: Value) -> Self {
view[keyPath: keyPath] = value
return self
extension UIViewContainer: UIViewContaining {
public func update(_ uiView: Child, coordinator: UIViewContainingCoordinator<Child>, updateContentSize: Bool) {
guard updateContentSize else { return }
coordinator.view?.updateContentSize()
}
}
Loading

0 comments on commit 56f2a1f

Please sign in to comment.