diff --git a/Sources/DynamicUI/Extensions/View.modifiers.swift b/Sources/DynamicUI/Extensions/View.modifiers.swift index abedf432a7..5903eb73ee 100644 --- a/Sources/DynamicUI/Extensions/View.modifiers.swift +++ b/Sources/DynamicUI/Extensions/View.modifiers.swift @@ -10,32 +10,30 @@ import SwiftUI -extension View { - /// DynamicUIModifiers - /// - /// This function adds modifiers to a DynamicUIView - /// - /// - Parameter modifiers: The modifiers to apply - /// - /// - Returns: The modified view - public func dynamicUIModifiers(_ modifiers: [String: AnyCodable]?) -> some View { - // swiftlint:disable:previous cyclomatic_complexity - guard let modifiers = modifiers else { - return AnyView(self) - } +public struct DynamicUIModifier: ViewModifier { + /// The modifiers to apply + let modifiers: [String: AnyCodable]? + let helper = DynamicUIHelper() - let helper = DynamicUIHelper() - var tempView = AnyView(self) + // TODO: Ideally, this function would use @ViewBuilder to avoid type erasure with AnyView, + // which would improve type safety and allow for more natural SwiftUI composition. + // However, applying modifiers dynamically based on a dictionary of keys and values + // currently requires type erasure, since @ViewBuilder expects a static view hierarchy. + // Investigate approaches to apply modifiers in a type-safe way without AnyView, + // possibly by refactoring how modifiers are represented or applied. + public func body(content: Content) -> some View { + // swiftlint:disable:next cyclomatic_complexity + var tempView = AnyView(content) - modifiers.forEach { key, value in + modifiers?.forEach { key, value in switch key { - case "foregroundStyle": + case "foregroundStyle", "foregroundColor": guard #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *), let string = value.toString(), let color = helper.translateColor(string) else { break } tempView = AnyView(tempView.foregroundStyle(color)) - case "backgroundStyle": + case "backgroundStyle", "backgroundColor": guard #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *), let string = value.toString(), let color = helper.translateColor(string) else { break } @@ -52,12 +50,40 @@ extension View { tempView = AnyView(tempView.font(.none)) case "frame": - // guard let color: - // minWidth: <#0#>, idealWidth: <#100#>, maxWidth: <#.infinity#>, - // minHeight: <#0#>, idealHeight: <#100#>, maxHeight: <#.infinity#>, alignment: <#.center#>) - // width: <#0#> height: <#0#> guard #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) else { break } - tempView = AnyView(tempView.frame()) + if let frameDict = value.toDictionary() { + let width = frameDict["width"]?.toDouble().map { CGFloat($0) } + let height = frameDict["height"]?.toDouble().map { CGFloat($0) } + let minWidth = frameDict["minWidth"]?.toDouble().map { CGFloat($0) } + let idealWidth = frameDict["idealWidth"]?.toDouble().map { CGFloat($0) } + let maxWidth = frameDict["maxWidth"]?.toDouble().map { CGFloat($0) } + let minHeight = frameDict["minHeight"]?.toDouble().map { CGFloat($0) } + let idealHeight = frameDict["idealHeight"]?.toDouble().map { CGFloat($0) } + let maxHeight = frameDict["maxHeight"]?.toDouble().map { CGFloat($0) } + let alignment = helper.translateAlignment(frameDict["alignment"]?.toString()) + + if width != nil || height != nil { + tempView = AnyView( + tempView.frame( + width: width, + height: height, + alignment: alignment + ) + ) + } else { + tempView = AnyView( + tempView.frame( + minWidth: minWidth, + idealWidth: idealWidth, + maxWidth: maxWidth, + minHeight: minHeight, + idealHeight: idealHeight, + maxHeight: maxHeight, + alignment: alignment + ) + ) + } + } case "padding": guard #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *), @@ -77,3 +103,30 @@ extension View { return tempView } } + +extension View { + /// DynamicUIModifiers + /// + /// This function adds modifiers to a DynamicUIView + /// + /// - Parameter modifiers: The modifiers to apply + /// + /// - Returns: The modified view + public func dynamicUIModifiers(_ modifiers: [String: AnyCodable]?) -> some View { + self.modifier(DynamicUIModifier(modifiers: modifiers)) + } +} + +#if DEBUG +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +#Preview { + Text("Test") + .dynamicUIModifiers([ + "frame": .dictionary([ + "width": .double(150), + "height": .double(100) + ]), + "foregroundStyle": .string("red") + ]) +} +#endif diff --git a/Sources/DynamicUI/Helpers/AnyCodable.swift b/Sources/DynamicUI/Helpers/AnyCodable.swift index be408fad63..416a8529cc 100644 --- a/Sources/DynamicUI/Helpers/AnyCodable.swift +++ b/Sources/DynamicUI/Helpers/AnyCodable.swift @@ -10,7 +10,8 @@ import Foundation -/// Any Codable supports different `Codable` types as `String`, `Int`, `Data`, `Double` and `Bool`. +/// Any Codable supports different `Codable` types as `String`, `Int`, `Data`, `Double`, `Bool`, +/// and nested dictionaries `[String: AnyCodable]`. /// This is made so you can use `AnyCodable?` in a codable struct so you can use dynamic types. /// /// Example: @@ -36,6 +37,9 @@ public enum AnyCodable { /// Boolean value case bool(Bool) + /// Dictionary value + case dictionary([String: AnyCodable]) + /// No value case none @@ -97,6 +101,16 @@ extension AnyCodable { return nil } + /// Convert value to Dictionary + /// - Returns: value if it is a dictionary + public func toDictionary() -> [String: AnyCodable]? { + if case let .dictionary(dict) = self { + return dict + } + + return nil + } + /// Check if value is nil /// - Returns: nil if value is none/empty public func isNil() -> Bool { @@ -108,15 +122,16 @@ extension AnyCodable { } } -extension AnyCodable: Codable, Hashable { +extension AnyCodable: Codable, Equatable, Hashable { enum CodingKeys: String, CodingKey { - case string, int, data, double, bool + case string, int, data, double, bool, dictionary } /// Decode the values - /// - /// - Parameter decoder: + /// + /// - Parameter decoder: public init(from decoder: Decoder) throws { + // Try to decode in order of most specific/common JSON types. if let int = try? decoder.singleValueContainer().decode(Int.self) { self = .int(int) return @@ -142,13 +157,18 @@ extension AnyCodable: Codable, Hashable { return } + if let dict = try? decoder.singleValueContainer().decode([String: AnyCodable].self) { + self = .dictionary(dict) + return + } + // Use `self = .none` if the value can be optional - // or `throw AnyCodableError.missingValue` is it may not be optional + // or `throw AnyCodableError.missingValue` if it may not be optional self = .none } /// Encode the values - /// + /// /// - Parameter encoder: Encoder public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -169,8 +189,48 @@ extension AnyCodable: Codable, Hashable { case .bool(let value): try container.encode(value, forKey: .bool) + case .dictionary(let value): + try container.encode(value, forKey: .dictionary) + case .none: _ = "" } } + + public func hash(into hasher: inout Hasher) { + switch self { + case .string(let value): + hasher.combine(0) + hasher.combine(value) + + case .int(let value): + hasher.combine(1) + hasher.combine(value) + + case .data(let value): + hasher.combine(2) + hasher.combine(value) + + case .double(let value): + hasher.combine(3) + hasher.combine(value) + + case .bool(let value): + hasher.combine(4) + hasher.combine(value) + + case .dictionary(let dict): + hasher.combine(5) + // Ensure deterministic hashing by sorting keys + for key in dict.keys.sorted() { + hasher.combine(key) + if let value = dict[key] { + hasher.combine(value) + } + } + + case .none: + hasher.combine(6) + } + } } diff --git a/Sources/DynamicUI/Helpers/DynamicUIHelper.swift b/Sources/DynamicUI/Helpers/DynamicUIHelper.swift index 87574fdaa9..8bb3c3f819 100644 --- a/Sources/DynamicUI/Helpers/DynamicUIHelper.swift +++ b/Sources/DynamicUI/Helpers/DynamicUIHelper.swift @@ -135,6 +135,19 @@ public class DynamicUIHelper { return .regular } } + + func translateAlignment(_ input: String?) -> Alignment { + switch input { + case "leading": + return .leading + case "center": + return .center + case "trailing": + return .trailing + default: + return .center + } + } } // swiftlint:enable cyclomatic_complexity function_body_length