Wire live activity widget patches
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// @ts-check
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { withDangerousMod } = require("@expo/config-plugins");
|
||||
|
||||
const widgetsPath = path.join("node_modules", "expo-widgets", "ios", "Widgets");
|
||||
const liveActivityBannerSource = `import SwiftUI
|
||||
import WidgetKit
|
||||
import ActivityKit
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct LiveActivityBanner: View {
|
||||
@Environment(\\.activityFamily) var activityFamily
|
||||
var context: ActivityViewContext<LiveActivityAttributes>
|
||||
var nodes: [String: Any]?
|
||||
|
||||
var body: some View {
|
||||
if let nodes, let node = nodes["banner"] as? [String: Any] {
|
||||
WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function patchWidgetLiveActivitySwift(source) {
|
||||
const target = ` if #available(iOS 18.0, *) {
|
||||
LiveActivityBanner(context: context, nodes: nodes)
|
||||
} else if let node = nodes["banner"] as? [String: Any] {
|
||||
WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
|
||||
} else {
|
||||
EmptyView()
|
||||
}`;
|
||||
const framedTarget = ` if #available(iOS 18.0, *) {
|
||||
LiveActivityBanner(context: context, nodes: nodes)
|
||||
} else if let node = nodes["banner"] as? [String: Any] {
|
||||
WidgetsDynamicView(name: context.activityID, kind: .liveActivity, node: node)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
EmptyView()
|
||||
}`;
|
||||
const hardcodedBanner = ` if #available(iOS 18.0, *) {
|
||||
LiveActivityBanner(context: context, nodes: nodes)
|
||||
} else {
|
||||
TimeClockNativeActivityBanner(context: context)
|
||||
}`;
|
||||
|
||||
if (source.includes(target)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
return source.replace(framedTarget, target).replace(hardcodedBanner, target);
|
||||
}
|
||||
|
||||
/** @type {import('@expo/config-plugins').ConfigPlugin} */
|
||||
function withLiveActivityBannerFrame(config) {
|
||||
return withDangerousMod(config, [
|
||||
"ios",
|
||||
async (config) => {
|
||||
const sourceDir = path.join(config.modRequest.projectRoot, widgetsPath);
|
||||
const bannerPath = path.join(sourceDir, "LiveActivityBanner.swift");
|
||||
const activityPath = path.join(sourceDir, "WidgetLiveActivity.swift");
|
||||
|
||||
if (!fs.existsSync(bannerPath) || !fs.existsSync(activityPath)) {
|
||||
throw new Error(`Could not find expo-widgets live activity sources in ${sourceDir}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(bannerPath, liveActivityBannerSource);
|
||||
|
||||
const activitySource = fs.readFileSync(activityPath, "utf8");
|
||||
const patchedActivitySource = patchWidgetLiveActivitySwift(activitySource);
|
||||
if (patchedActivitySource !== activitySource) {
|
||||
fs.writeFileSync(activityPath, patchedActivitySource);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = withLiveActivityBannerFrame;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user