Skip to content

Commit fcfe315

Browse files
committed
UIKitBackend rough draft (woefully incomplete but arguably functional)
1 parent 63fde71 commit fcfe315

File tree

6 files changed

+499
-2
lines changed

6 files changed

+499
-2
lines changed

Package.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ switch ProcessInfo.processInfo.environment["SCUI_LIBRARY_TYPE"] {
6161

6262
let package = Package(
6363
name: "swift-cross-ui",
64-
platforms: [.macOS(.v10_15)],
64+
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13)],
6565
products: [
6666
.library(name: "SwiftCrossUI", type: libraryType, targets: ["SwiftCrossUI"]),
6767
.library(name: "AppKitBackend", type: libraryType, targets: ["AppKitBackend"]),
@@ -145,7 +145,11 @@ let package = Package(
145145
.target(
146146
name: "DefaultBackend",
147147
dependencies: [
148-
.target(name: defaultBackend)
148+
.target(name: defaultBackend, condition: .when(platforms: [.linux, .macOS, .windows])),
149+
// Non-desktop platforms need to be handled separately:
150+
// Only one backend is supported, and `#if` won't work because it's evaluated
151+
// on the compiling desktop, not the target.
152+
.target(name: "UIKitBackend", condition: .when(platforms: [.iOS, .tvOS])),
149153
]
150154
),
151155
.target(name: "AppKitBackend", dependencies: ["SwiftCrossUI"]),
@@ -219,6 +223,7 @@ let package = Package(
219223
],
220224
swiftSettings: swiftSettings
221225
),
226+
.target(name: "UIKitBackend", dependencies: ["SwiftCrossUI"]),
222227
.target(
223228
name: "WinUIBackend",
224229
dependencies: [

Sources/DefaultBackend/DefaultBackend.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
#elseif canImport(CursesBackend)
1717
import CursesBackend
1818
public typealias DefaultBackend = CursesBackend
19+
#elseif canImport(UIKitBackend)
20+
import UIKitBackend
21+
public typealias DefaultBackend = UIKitBackend
1922
#else
2023
#error("Unknown backend selected")
2124
#endif
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//
2+
// UIKitBackend+Container.swift
3+
// swift-cross-ui
4+
//
5+
// Created by William Baker on 1/9/25.
6+
//
7+
8+
import SwiftCrossUI
9+
import UIKit
10+
11+
extension UIKitBackend {
12+
public func createContainer() -> Widget {
13+
UIView()
14+
}
15+
16+
public func removeAllChildren(of container: Widget) {
17+
container.subviews.forEach { $0.removeFromSuperview() }
18+
}
19+
20+
public func addChild(_ child: Widget, to container: Widget) {
21+
container.addSubview(child)
22+
child.translatesAutoresizingMaskIntoConstraints = false
23+
}
24+
25+
public func setPosition(
26+
ofChildAt index: Int,
27+
in container: Widget,
28+
to position: SIMD2<Int>
29+
) {
30+
guard index < container.subviews.count else {
31+
assertionFailure("Attempting to set position of nonexistent subview")
32+
return
33+
}
34+
35+
let child = container.subviews[index]
36+
37+
UIKitBackend.createOrUpdateConstraint(
38+
in: child,
39+
from: child.leftAnchor,
40+
to: container.leftAnchor,
41+
constant: CGFloat(position.x)
42+
)
43+
UIKitBackend.createOrUpdateConstraint(
44+
in: child,
45+
from: child.topAnchor,
46+
to: container.topAnchor,
47+
constant: CGFloat(position.y)
48+
)
49+
}
50+
51+
public func removeChild(_ child: Widget, from container: Widget) {
52+
assert(child.isDescendant(of: container))
53+
child.removeFromSuperview()
54+
}
55+
56+
public func createColorableRectangle() -> Widget {
57+
UIView()
58+
}
59+
60+
public func setColor(ofColorableRectangle widget: Widget, to color: Color) {
61+
let uiColor = UIColor(
62+
red: CGFloat(color.red),
63+
green: CGFloat(color.green),
64+
blue: CGFloat(color.blue),
65+
alpha: CGFloat(color.alpha)
66+
)
67+
widget.backgroundColor = uiColor
68+
}
69+
70+
public func setCornerRadius(of widget: Widget, to radius: Int) {
71+
widget.layer.cornerRadius = CGFloat(radius)
72+
widget.layer.masksToBounds = true
73+
widget.setNeedsLayout()
74+
}
75+
76+
public func naturalSize(of widget: Widget) -> SIMD2<Int> {
77+
let size = widget.intrinsicContentSize
78+
return SIMD2(
79+
Int(size.width),
80+
Int(size.height)
81+
)
82+
}
83+
84+
public func setSize(of widget: Widget, to size: SIMD2<Int>) {
85+
widget.translatesAutoresizingMaskIntoConstraints = false
86+
UIKitBackend.createOrUpdateConstraint(
87+
in: widget,
88+
of: widget.widthAnchor,
89+
constant: CGFloat(size.x)
90+
)
91+
UIKitBackend.createOrUpdateConstraint(
92+
in: widget,
93+
of: widget.heightAnchor,
94+
constant: CGFloat(size.y)
95+
)
96+
}
97+
98+
public func createScrollContainer(for child: Widget) -> Widget {
99+
let scrollView = UIScrollView()
100+
scrollView.addSubview(child)
101+
102+
child.translatesAutoresizingMaskIntoConstraints = false
103+
UIKitBackend.createOrUpdateConstraint(
104+
in: child,
105+
from: child.leadingAnchor,
106+
to: scrollView.leadingAnchor,
107+
constant: 0.0
108+
)
109+
UIKitBackend.createOrUpdateConstraint(
110+
in: child,
111+
from: child.trailingAnchor,
112+
to: scrollView.trailingAnchor,
113+
constant: 0.0
114+
)
115+
UIKitBackend.createOrUpdateConstraint(
116+
in: child,
117+
from: child.topAnchor,
118+
to: scrollView.topAnchor,
119+
constant: 0.0
120+
)
121+
UIKitBackend.createOrUpdateConstraint(
122+
in: child,
123+
from: child.bottomAnchor,
124+
to: scrollView.bottomAnchor,
125+
constant: 0.0
126+
)
127+
128+
return scrollView
129+
}
130+
131+
internal static func createOrUpdateConstraint<Anchor: NSObject>(
132+
in view: UIView,
133+
from firstAnchor: NSLayoutAnchor<Anchor>,
134+
to secondAnchor: NSLayoutAnchor<Anchor>,
135+
constant: CGFloat
136+
) {
137+
if let constraint = view.constraints.first(where: {
138+
$0.firstAnchor === firstAnchor && $0.secondAnchor === secondAnchor
139+
}) {
140+
constraint.constant = constant
141+
} else {
142+
firstAnchor.constraint(equalTo: secondAnchor, constant: constant).isActive = true
143+
}
144+
}
145+
146+
internal static func createOrUpdateConstraint(
147+
in view: UIView,
148+
of anchor: NSLayoutDimension,
149+
constant: CGFloat
150+
) {
151+
if let constraint = view.constraints.first(where: { $0.firstAnchor === anchor }) {
152+
constraint.constant = constant
153+
} else {
154+
anchor.constraint(equalToConstant: constant).isActive = true
155+
}
156+
}
157+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//
2+
// UIKitBackend+Passive.swift
3+
// swift-cross-ui
4+
//
5+
// Created by William Baker on 1/9/25.
6+
//
7+
8+
import SwiftCrossUI
9+
import UIKit
10+
11+
extension UIKitBackend {
12+
internal static func uiFont(for font: Font) -> UIFont {
13+
switch font {
14+
case .system(size: let size, weight: let weight, design: let design):
15+
let weight: UIFont.Weight = switch weight {
16+
case .black:
17+
.black
18+
case .bold:
19+
.bold
20+
case .heavy:
21+
.heavy
22+
case .light:
23+
.light
24+
case .medium:
25+
.medium
26+
case .regular, nil:
27+
.regular
28+
case .semibold:
29+
.semibold
30+
case .thin:
31+
.thin
32+
case .ultraLight:
33+
.ultraLight
34+
}
35+
36+
switch design {
37+
case .monospaced:
38+
return .monospacedSystemFont(ofSize: CGFloat(size), weight: weight)
39+
default:
40+
return .systemFont(ofSize: CGFloat(size), weight: weight)
41+
}
42+
}
43+
}
44+
45+
internal static func attributedString(
46+
text: String,
47+
environment: EnvironmentValues
48+
) -> NSAttributedString {
49+
let paragraphStyle = NSMutableParagraphStyle()
50+
paragraphStyle.alignment = switch environment.multilineTextAlignment {
51+
case .center:
52+
.center
53+
case .leading:
54+
.natural
55+
case .trailing:
56+
UIScreen.main.traitCollection.layoutDirection == .rightToLeft ? .left : .right
57+
}
58+
paragraphStyle.lineBreakMode = .byWordWrapping
59+
60+
return NSAttributedString(
61+
string: text,
62+
attributes: [
63+
.font: uiFont(for: environment.font),
64+
.foregroundColor: UIColor(
65+
red: CGFloat(environment.suggestedForegroundColor.red),
66+
green: CGFloat(environment.suggestedForegroundColor.green),
67+
blue: CGFloat(environment.suggestedForegroundColor.blue),
68+
alpha: CGFloat(environment.suggestedForegroundColor.alpha)
69+
),
70+
.paragraphStyle: paragraphStyle
71+
]
72+
)
73+
}
74+
75+
public func createTextView() -> Widget {
76+
UILabel()
77+
}
78+
79+
public func updateTextView(
80+
_ textView: Widget,
81+
content: String,
82+
environment: EnvironmentValues
83+
) {
84+
let label = textView as! UILabel
85+
label.attributedText = UIKitBackend.attributedString(text: content, environment: environment)
86+
}
87+
88+
public func size(
89+
of text: String,
90+
whenDisplayedIn textView: Widget,
91+
proposedFrame: SIMD2<Int>?,
92+
environment: EnvironmentValues
93+
) -> SIMD2<Int> {
94+
let attributedString = UIKitBackend.attributedString(text: text, environment: environment)
95+
let boundingSize = if let proposedFrame { CGSize(width: CGFloat(proposedFrame.x), height: .greatestFiniteMagnitude) } else {
96+
CGSize(width: .greatestFiniteMagnitude, height: UIKitBackend.uiFont(for: environment.font).lineHeight)
97+
}
98+
let size = attributedString.boundingRect(
99+
with: boundingSize,
100+
options: proposedFrame == nil ? [] : [.usesLineFragmentOrigin],
101+
context: nil
102+
)
103+
return SIMD2(
104+
Int(size.width.rounded(.awayFromZero)),
105+
Int(size.height.rounded(.awayFromZero))
106+
)
107+
}
108+
109+
public func createImageView() -> Widget {
110+
UIImageView()
111+
}
112+
113+
public func updateImageView(
114+
_ imageView: Widget,
115+
rgbaData: [UInt8],
116+
width: Int,
117+
height: Int,
118+
targetWidth: Int,
119+
targetHeight: Int,
120+
dataHasChanged: Bool
121+
) {
122+
guard dataHasChanged else { return }
123+
let uiImageView = imageView as! UIImageView
124+
let ciImage = CIImage(
125+
bitmapData: Data(rgbaData),
126+
bytesPerRow: width * 4,
127+
size: CGSize(width: CGFloat(width), height: CGFloat(height)),
128+
format: .RGBA8,
129+
colorSpace: .init(name: CGColorSpace.sRGB)
130+
)
131+
uiImageView.image = .init(ciImage: ciImage)
132+
}
133+
}

0 commit comments

Comments
 (0)