Wire live activity widget patches

This commit is contained in:
2026-07-01 02:45:30 -04:00
parent 107b593002
commit 6497ee4dec
5 changed files with 348 additions and 2 deletions
+256
View File
@@ -0,0 +1,256 @@
// @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;