257 lines
8.6 KiB
JavaScript
257 lines
8.6 KiB
JavaScript
// @ts-check
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { withDangerousMod } = require("@expo/config-plugins");
|
|
|
|
const dynamicViewPath = path.join(
|
|
"node_modules",
|
|
"expo-widgets",
|
|
"ios",
|
|
"Widgets",
|
|
"DynamicView.swift",
|
|
);
|
|
|
|
// expo-widgets rebuilds every WidgetsDynamicView child with a brand-new random UUID
|
|
// on every single render (see the original "Hack to satisfy ExpoSwiftUI.AnyChild"
|
|
// comment), so the `ForEach(props.children, id: \.id)` that HStack/VStack use to
|
|
// render their children sees a totally different identity set on each re-render.
|
|
// SwiftUI then treats every child as removed-and-reinserted instead of updated in
|
|
// place, which can make children silently fail to render whenever the parent's
|
|
// layout is recomputed (e.g. after a `.frame(maxWidth:)` change). This patch gives
|
|
// each child a stable identity derived from its structural position, cached across
|
|
// renders, so ForEach can correctly diff instead of thrashing.
|
|
const patchedSource = `import SwiftUI
|
|
import ExpoModulesCore
|
|
import ExpoUI
|
|
|
|
// TODO(@jakex7): Hack to satisfy ExpoSwiftUI.AnyChild with random UUID value
|
|
class NodeIdentityWrapper {
|
|
let id: UUID
|
|
init(id: UUID) {
|
|
self.id = id
|
|
}
|
|
}
|
|
|
|
// Reuses the same NodeIdentityWrapper for a given structural key across re-renders,
|
|
// so ForEach(id: \\.id) sees a stable identity instead of a fresh UUID every render.
|
|
private final class NodeIdentityCache {
|
|
static let shared = NodeIdentityCache()
|
|
private var wrappers: [String: NodeIdentityWrapper] = [:]
|
|
|
|
func wrapper(for key: String) -> NodeIdentityWrapper {
|
|
if let existing = wrappers[key] {
|
|
return existing
|
|
}
|
|
let created = NodeIdentityWrapper(id: UUID())
|
|
wrappers[key] = created
|
|
return created
|
|
}
|
|
}
|
|
|
|
extension ObjectIdentifier: @retroactive Encodable {
|
|
public func encode(to encoder: Encoder) throws {
|
|
var container = encoder.singleValueContainer()
|
|
try container.encode(String(describing: self))
|
|
}
|
|
}
|
|
|
|
public struct WidgetsDynamicView: View, ExpoSwiftUI.AnyChild {
|
|
let node: [String: Any]
|
|
let name: String
|
|
let kind: WidgetsKind
|
|
let entryIndex: Int?
|
|
let environmentString: String?
|
|
let childKey: String?
|
|
|
|
private var uuid: NodeIdentityWrapper {
|
|
guard let childKey else {
|
|
return NodeIdentityWrapper(id: UUID())
|
|
}
|
|
return NodeIdentityCache.shared.wrapper(for: childKey)
|
|
}
|
|
public var id: ObjectIdentifier {
|
|
ObjectIdentifier(uuid)
|
|
}
|
|
|
|
public init(name: String, kind: WidgetsKind, node: [String: Any]) {
|
|
self.name = name
|
|
self.kind = kind
|
|
self.node = node
|
|
self.entryIndex = nil
|
|
self.environmentString = nil
|
|
self.childKey = node["type"] as? String
|
|
}
|
|
|
|
public init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?) {
|
|
self.name = name
|
|
self.kind = kind
|
|
self.node = node
|
|
self.entryIndex = entryIndex
|
|
self.environmentString = environmentString
|
|
self.childKey = node["type"] as? String
|
|
}
|
|
|
|
private init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?, childKey: String) {
|
|
self.name = name
|
|
self.kind = kind
|
|
self.node = node
|
|
self.entryIndex = entryIndex
|
|
self.environmentString = environmentString
|
|
self.childKey = childKey
|
|
}
|
|
|
|
@ViewBuilder
|
|
public var body: some View {
|
|
switch node["type"] as? String {
|
|
case "TextView":
|
|
render(TextView.self, TextViewProps.self, updateProps: updateChildren)
|
|
case "HStackView":
|
|
render(HStackView.self, HStackViewProps.self, updateProps: updateChildren)
|
|
case "VStackView":
|
|
render(VStackView.self, VStackViewProps.self, updateProps: updateChildren)
|
|
case "ZStackView":
|
|
render(ZStackView.self, ZStackViewProps.self, updateProps: updateChildren)
|
|
case "RectangleView":
|
|
render(RectangleView.self, RectangleViewProps.self)
|
|
case "RoundedRectangleView":
|
|
render(RoundedRectangleView.self, RoundedRectangleViewProps.self)
|
|
case "CapsuleView":
|
|
render(CapsuleView.self, CapsuleViewProps.self)
|
|
case "CircleView":
|
|
render(CircleView.self, CircleViewProps.self)
|
|
case "ImageView":
|
|
render(ImageView.self, ImageViewProps.self)
|
|
case "AccessoryWidgetBackgroundView":
|
|
render(AccessoryWidgetBackgroundView.self, AccessoryWidgetBackgroundProps.self)
|
|
case "DividerView":
|
|
render(DividerView.self, DividerProps.self)
|
|
case "EllipseView":
|
|
render(EllipseView.self, EllipseViewProps.self)
|
|
case "LabelView":
|
|
render(LabelView.self, LabelViewProps.self)
|
|
case "ProgressView":
|
|
render(ProgressView.self, ProgressViewProps.self)
|
|
case "SpacerView":
|
|
render(SpacerView.self, SpacerViewProps.self)
|
|
case "UnevenRoundedRectangleView":
|
|
render(UnevenRoundedRectangleView.self, UnevenRoundedRectangleViewProps.self)
|
|
case "GaugeView":
|
|
render(GaugeView.self, GaugeProps.self)
|
|
case "ChartView":
|
|
render(ChartView.self, ChartProps.self)
|
|
case "Button":
|
|
if #available(iOS 17.0, *) {
|
|
switch kind {
|
|
case .widget:
|
|
render(WidgetButtonView.self, ButtonProps.self) { buttonProps in
|
|
try updateChildren(buttonProps)
|
|
buttonProps.source = name
|
|
buttonProps.entryIndex = entryIndex
|
|
buttonProps.environmentString = environmentString
|
|
}
|
|
case .liveActivity:
|
|
render(LiveActivityButtonView.self, ButtonProps.self) { buttonProps in
|
|
try updateChildren(buttonProps)
|
|
buttonProps.source = name
|
|
}
|
|
}
|
|
} else {
|
|
render(ExpoUI.Button.self, ExpoUI.ButtonProps.self, updateProps: updateChildren)
|
|
}
|
|
case "react.fragment":
|
|
render(FragmentView.self, FragmentProps.self, updateProps: updateChildren)
|
|
case "LinkView":
|
|
render(LinkView.self, LinkViewProps.self, updateProps: updateChildren)
|
|
#if DEBUG
|
|
case "RedBoxView":
|
|
render(RedBoxView.self, RedBoxViewProps.self) { redBoxProps in
|
|
redBoxProps.source = name
|
|
redBoxProps.kind = kind
|
|
}
|
|
default:
|
|
ZStack {
|
|
Color.red.opacity(0.5)
|
|
Text("Unable to get the view for: \\(node["type"] as? String ?? "undefined")")
|
|
}
|
|
#else
|
|
default:
|
|
EmptyView()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - Render Method
|
|
|
|
@ViewBuilder
|
|
private func render<P, V>(_ viewType: V.Type, _ propsType: P.Type, updateProps: ((_ initialProps: P) throws -> Void)? = nil) -> some View
|
|
where P: UIBaseViewProps, V: ExpoSwiftUI.View, V.Props == P {
|
|
// immediately invoked closure {}() here because we can't use 'do-catch' inside @ViewBuilder
|
|
{
|
|
do {
|
|
if let rawProps = node["props"] as? [String: Any] {
|
|
let props = try propsType.init(rawProps: rawProps, context: WidgetsContext.shared.context)
|
|
try updateProps?(props)
|
|
return AnyView(UIBaseView<P, V>(props: props).transition(.identity))
|
|
}
|
|
return AnyView(EmptyView())
|
|
} catch {
|
|
return AnyView(EmptyView())
|
|
}
|
|
}()
|
|
}
|
|
|
|
// MARK: - Function that sets children as DynamicView
|
|
|
|
private func updateChildren<P>(_ initialProps: P) throws
|
|
where P: UIBaseViewProps {
|
|
let baseKey = childKey ?? (node["type"] as? String ?? "root")
|
|
if let props = node["props"] as? [String: Any] {
|
|
if let children = props["children"] as? [Any] {
|
|
let validChildren = children.compactMap { $0 as? [String: Any] }
|
|
initialProps.children = validChildren.enumerated().map { index, childNode in
|
|
let childType = childNode["type"] as? String ?? "unknown"
|
|
return WidgetsDynamicView(
|
|
name: name,
|
|
kind: kind,
|
|
node: childNode,
|
|
entryIndex: entryIndex,
|
|
environmentString: environmentString,
|
|
childKey: "\\(baseKey).\\(index).\\(childType)"
|
|
)
|
|
}
|
|
} else if let child = props["children"] as? [String: Any] {
|
|
let childType = child["type"] as? String ?? "unknown"
|
|
initialProps.children = [WidgetsDynamicView(
|
|
name: name,
|
|
kind: kind,
|
|
node: child,
|
|
entryIndex: entryIndex,
|
|
environmentString: environmentString,
|
|
childKey: "\\(baseKey).0.\\(childType)"
|
|
)]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
|
function withStableWidgetsChildIdentity(config) {
|
|
return withDangerousMod(config, [
|
|
"ios",
|
|
async (config) => {
|
|
const filePath = path.join(config.modRequest.projectRoot, dynamicViewPath);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
throw new Error(`Could not find expo-widgets DynamicView.swift at ${filePath}`);
|
|
}
|
|
|
|
fs.writeFileSync(filePath, patchedSource);
|
|
|
|
return config;
|
|
},
|
|
]);
|
|
}
|
|
|
|
module.exports = withStableWidgetsChildIdentity;
|