From 9df363d2f98fb04a2ff0d861affd903e5d3cada5 Mon Sep 17 00:00:00 2001 From: Guille Gonzalez Date: Thu, 8 Oct 2020 15:38:00 +0200 Subject: [PATCH] Render ImageSet elements --- .../project.pbxproj | 6 ++ .../AdaptiveCardVisualizer/Cards/Photos.json | 69 ++++++++++++++++ .../Shared/SampleCard.swift | 7 ++ .../CardElement+FeatureAdaptable.swift | 2 +- .../AdaptiveCard+DuplicateIdentifiers.swift | 2 + .../Logic/Store/AdaptiveCard+ImageURLs.swift | 2 + .../CardElement+Toggleable.swift | 10 +++ .../ToggleVisibility/Image+Toggleable.swift | 15 ++++ .../Model/Containers/ImageSet.swift | 57 ++++++++++++++ .../Model/Elements/CardElement.swift | 12 +++ .../UI/CardElement/CardElementList.swift | 2 +- .../UI/CardElement/CardElementView.swift | 2 + .../UI/Helpers/FlowLayout.swift | 41 ++++++++++ .../UI/Image/ImageSetView.swift | 74 ++++++++++++++++++ .../Logic/AdaptiveCardImageURLsTests.swift | 6 ++ .../AdaptiveCardToggleVisibilityTests.swift | 37 +++++++++ .../UI/ImageSetRenderingTests.swift | 23 ++++++ .../UI/__Fixtures__/imageSet.json | 40 ++++++++++ .../ImageSetRenderingTests/testImageSet.1.png | Bin 0 -> 14748 bytes 19 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 Examples/AdaptiveCardVisualizer/Cards/Photos.json create mode 100644 Sources/AdaptiveCardUI/Logic/ToggleVisibility/Image+Toggleable.swift create mode 100644 Sources/AdaptiveCardUI/Model/Containers/ImageSet.swift create mode 100644 Sources/AdaptiveCardUI/UI/Helpers/FlowLayout.swift create mode 100644 Sources/AdaptiveCardUI/UI/Image/ImageSetView.swift create mode 100644 Tests/AdaptiveCardUITests/UI/ImageSetRenderingTests.swift create mode 100644 Tests/AdaptiveCardUITests/UI/__Fixtures__/imageSet.json create mode 100644 Tests/AdaptiveCardUITests/UI/__Snapshots__/ImageSetRenderingTests/testImageSet.1.png diff --git a/Examples/AdaptiveCardVisualizer/AdaptiveCardVisualizer.xcodeproj/project.pbxproj b/Examples/AdaptiveCardVisualizer/AdaptiveCardVisualizer.xcodeproj/project.pbxproj index d490b75..1154db0 100644 --- a/Examples/AdaptiveCardVisualizer/AdaptiveCardVisualizer.xcodeproj/project.pbxproj +++ b/Examples/AdaptiveCardVisualizer/AdaptiveCardVisualizer.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 82C107E8252F7CA60046764A /* Photos.json in Resources */ = {isa = PBXBuildFile; fileRef = 82C107E7252F7CA60046764A /* Photos.json */; }; + 82C107E9252F7CA60046764A /* Photos.json in Resources */ = {isa = PBXBuildFile; fileRef = 82C107E7252F7CA60046764A /* Photos.json */; }; 9913B76B2514D5EC002C695C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9913B7562514D5EC002C695C /* Assets.xcassets */; }; 9913B76C2514D5EC002C695C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9913B7562514D5EC002C695C /* Assets.xcassets */; }; 9913B7822514D695002C695C /* ActivityUpdate.json in Resources */ = {isa = PBXBuildFile; fileRef = 9913B7812514D695002C695C /* ActivityUpdate.json */; }; @@ -62,6 +64,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 82C107E7252F7CA60046764A /* Photos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Photos.json; sourceTree = ""; }; 9913B7562514D5EC002C695C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 9913B75B2514D5EC002C695C /* AdaptiveCardVisualizer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AdaptiveCardVisualizer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9913B75E2514D5EC002C695C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -186,6 +189,7 @@ 9913B7D92514F18F002C695C /* WeatherCompact.json */, 9913B7DE2514F458002C695C /* WeatherLarge.json */, 9913B7ED2514F6A8002C695C /* FlightDetails.json */, + 82C107E7252F7CA60046764A /* Photos.json */, ); path = Cards; sourceTree = ""; @@ -311,6 +315,7 @@ 9913B78A2514D6B5002C695C /* prism.js in Resources */, 9913B7D52514E065002C695C /* StockUpdate.json in Resources */, 9913B7822514D695002C695C /* ActivityUpdate.json in Resources */, + 82C107E8252F7CA60046764A /* Photos.json in Resources */, 9913B7DF2514F458002C695C /* WeatherLarge.json in Resources */, 9913B78E2514D6B5002C695C /* okaidia.css in Resources */, 9913B7CB2514DB63002C695C /* FlightUpdate.json in Resources */, @@ -331,6 +336,7 @@ 9913B78B2514D6B5002C695C /* prism.js in Resources */, 9913B7D62514E065002C695C /* StockUpdate.json in Resources */, 9913B7832514D695002C695C /* ActivityUpdate.json in Resources */, + 82C107E9252F7CA60046764A /* Photos.json in Resources */, 9913B7E02514F458002C695C /* WeatherLarge.json in Resources */, 9913B78F2514D6B5002C695C /* okaidia.css in Resources */, 9913B7CC2514DB63002C695C /* FlightUpdate.json in Resources */, diff --git a/Examples/AdaptiveCardVisualizer/Cards/Photos.json b/Examples/AdaptiveCardVisualizer/Cards/Photos.json new file mode 100644 index 0000000..6a4010d --- /dev/null +++ b/Examples/AdaptiveCardVisualizer/Cards/Photos.json @@ -0,0 +1,69 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "Here are some cool photos", + "size": "large" + }, + { + "type": "TextBlock", + "text": "from picsum.photos", + "size": "medium", + "weight": "lighter", + "spacing": "none" + }, + { + "type": "ImageSet", + "images": [ + { + "type": "Image", + "url": "https://picsum.photos/200/200?image=100", + "altText": "White beach panorama" + }, + { + "type": "Image", + "url": "https://picsum.photos/300/200?image=200", + "altText": "Cow on a grassy field" + }, + { + "type": "Image", + "url": "https://picsum.photos/300/200?image=301", + "altText": "Orange leaves on the sidewalk of a park" + }, + { + "type": "Image", + "url": "https://picsum.photos/200/200?image=400", + "altText": "Green leaves" + }, + { + "type": "Image", + "url": "https://picsum.photos/300/200?image=500", + "altText": "Top of a sky scrapper" + }, + { + "type": "Image", + "url": "https://picsum.photos/200/200?image=600", + "altText": "Foggy forest" + }, + { + "type": "Image", + "url": "https://picsum.photos/300/200?image=700", + "altText": "Picure of the blue ocean" + }, + { + "type": "Image", + "url": "https://picsum.photos/300/200?image=800", + "altText": "Crowded train station" + }, + { + "type": "Image", + "url": "https://picsum.photos/300/200?image=900", + "altText": "Sunset under a dock" + } + ] + } + ] +} diff --git a/Examples/AdaptiveCardVisualizer/Shared/SampleCard.swift b/Examples/AdaptiveCardVisualizer/Shared/SampleCard.swift index 7093a2d..3754f91 100644 --- a/Examples/AdaptiveCardVisualizer/Shared/SampleCard.swift +++ b/Examples/AdaptiveCardVisualizer/Shared/SampleCard.swift @@ -61,6 +61,12 @@ extension SampleCard { resourceName: "FlightDetails.json" ) + static let photos = SampleCard( + id: "Photos", + title: "Photos", + resourceName: "Photos.json" + ) + static let all: [SampleCard] = [ .gitHubRepository, .activityUpdate, @@ -71,5 +77,6 @@ extension SampleCard { .weatherCompact, .weatherLarge, .flightDetails, + .photos, ] } diff --git a/Sources/AdaptiveCardUI/Logic/FeatureAdaptation/CardElement+FeatureAdaptable.swift b/Sources/AdaptiveCardUI/Logic/FeatureAdaptation/CardElement+FeatureAdaptable.swift index e4b59ec..9be49cf 100644 --- a/Sources/AdaptiveCardUI/Logic/FeatureAdaptation/CardElement+FeatureAdaptable.swift +++ b/Sources/AdaptiveCardUI/Logic/FeatureAdaptation/CardElement+FeatureAdaptable.swift @@ -9,7 +9,7 @@ extension CardElement: FeatureAdaptable { } switch self { - case .textBlock, .image, .richTextBlock, .factSet, .custom: + case .textBlock, .image, .richTextBlock, .factSet, .imageSet, .custom: return self case var .actionSet(actionSet): var elementShouldFallback = false diff --git a/Sources/AdaptiveCardUI/Logic/Store/AdaptiveCard+DuplicateIdentifiers.swift b/Sources/AdaptiveCardUI/Logic/Store/AdaptiveCard+DuplicateIdentifiers.swift index 8841fc7..d94ad92 100644 --- a/Sources/AdaptiveCardUI/Logic/Store/AdaptiveCard+DuplicateIdentifiers.swift +++ b/Sources/AdaptiveCardUI/Logic/Store/AdaptiveCard+DuplicateIdentifiers.swift @@ -27,6 +27,8 @@ private extension CardElement { return [container.id] + container.items.flatMap(\.identifiers) case let .columnSet(columnSet): return [columnSet.id] + columnSet.columns.flatMap(\.identifiers) + case let .imageSet(imageSet): + return [imageSet.id] + imageSet.images.map(\.id) case .unknown: return [] } diff --git a/Sources/AdaptiveCardUI/Logic/Store/AdaptiveCard+ImageURLs.swift b/Sources/AdaptiveCardUI/Logic/Store/AdaptiveCard+ImageURLs.swift index 026bd29..c732d16 100644 --- a/Sources/AdaptiveCardUI/Logic/Store/AdaptiveCard+ImageURLs.swift +++ b/Sources/AdaptiveCardUI/Logic/Store/AdaptiveCard+ImageURLs.swift @@ -28,6 +28,8 @@ private extension CardElement { [container.backgroundImage?.url].compactMap { $0 } case let .columnSet(columnSet): return columnSet.columns.flatMap(\.imageURLs) + case let .imageSet(imageSet): + return imageSet.images.map(\.url) } } } diff --git a/Sources/AdaptiveCardUI/Logic/ToggleVisibility/CardElement+Toggleable.swift b/Sources/AdaptiveCardUI/Logic/ToggleVisibility/CardElement+Toggleable.swift index 9672c22..b1af53a 100644 --- a/Sources/AdaptiveCardUI/Logic/ToggleVisibility/CardElement+Toggleable.swift +++ b/Sources/AdaptiveCardUI/Logic/ToggleVisibility/CardElement+Toggleable.swift @@ -21,6 +21,13 @@ extension CardElement: Toggleable { } else { return false } + case var .imageSet(imageSet): + if imageSet.images.toggleVisibility(of: target) { + self = .imageSet(imageSet) + return true + } else { + return false + } case .textBlock, .image, .richTextBlock, .actionSet, .factSet, .custom, .unknown: return false } @@ -52,6 +59,9 @@ private extension CardElement { case var .factSet(element): element.toggleVisibility(isVisible) self = .factSet(element) + case var .imageSet(element): + element.toggleVisibility(isVisible) + self = .imageSet(element) case var .custom(element): element.toggleVisibility(isVisible) self = .custom(element) diff --git a/Sources/AdaptiveCardUI/Logic/ToggleVisibility/Image+Toggleable.swift b/Sources/AdaptiveCardUI/Logic/ToggleVisibility/Image+Toggleable.swift new file mode 100644 index 0000000..cf24527 --- /dev/null +++ b/Sources/AdaptiveCardUI/Logic/ToggleVisibility/Image+Toggleable.swift @@ -0,0 +1,15 @@ +import Foundation + +extension Image: Toggleable { + mutating func toggleVisibility(of target: TargetElement) -> Bool { + guard id == target.elementId else { return false } + + if let isVisible = target.isVisible { + self.isVisible = isVisible + } else { + isVisible.toggle() + } + + return true + } +} diff --git a/Sources/AdaptiveCardUI/Model/Containers/ImageSet.swift b/Sources/AdaptiveCardUI/Model/Containers/ImageSet.swift new file mode 100644 index 0000000..f4ebf3e --- /dev/null +++ b/Sources/AdaptiveCardUI/Model/Containers/ImageSet.swift @@ -0,0 +1,57 @@ +import DefaultCodable +import Foundation + +public enum MediumImageSize: DefaultValueProvider { + public static var `default` = ImageSize.medium +} + +/// The `ImageSet` element displays a collection of images. +public struct ImageSet: CardElementProtocol, Codable, Equatable { + /// A unique identifier associated with the item. + @ItemIdentifier public var id: String + + /// If `false`, this item will be removed from the visual tree. + @Default public var isVisible: Bool + + /// When `true`, draw a separating line at the top of the element. + @Default public var separator: Bool + + /// Controls the amount of spacing between this element and the preceding element. + @Default public var spacing: Spacing + + /// Describes what to do when an unknown element is encountered or the requires of this or any children can’t be met. + @Default public var fallback: Fallback + + /// A series of key/value pairs indicating features that the item requires with corresponding minimum version. + /// When a feature is missing or of insufficient version, fallback is triggered. + @Default public var requires: [String: SemanticVersion] + + /// The array of `Image` elements to show. + public var images: [Image] + + /// Controls the approximate size of each image. + /// + /// `auto` and `stretch` are not supported for ImageSet. The size will + /// default to `medium` if those values are set. + @Default public var imageSize: ImageSize + + public init( + id: String = "", + isVisible: Bool = true, + separator: Bool = false, + spacing: Spacing = .default, + fallback: Fallback = .none, + requires: [String: SemanticVersion] = [:], + images: [Image], + imageSize: ImageSize = .medium + ) { + self.id = id + self.isVisible = isVisible + self.separator = separator + self.spacing = spacing + self.fallback = fallback + self.requires = requires + self.images = images + self.imageSize = imageSize + } +} diff --git a/Sources/AdaptiveCardUI/Model/Elements/CardElement.swift b/Sources/AdaptiveCardUI/Model/Elements/CardElement.swift index 0ed5d82..7f837cc 100644 --- a/Sources/AdaptiveCardUI/Model/Elements/CardElement.swift +++ b/Sources/AdaptiveCardUI/Model/Elements/CardElement.swift @@ -24,6 +24,9 @@ public indirect enum CardElement { /// A series of facts (i.e. name / value pairs) in a tabular form. case factSet(FactSet) + /// A collection of images. + case imageSet(ImageSet) + /// A custom card element. case custom(CustomCardElement) @@ -56,6 +59,8 @@ extension CardElement: Codable { self = .columnSet(try ColumnSet(from: decoder)) case String(describing: FactSet.self): self = .factSet(try FactSet(from: decoder)) + case String(describing: ImageSet.self): + self = .imageSet(try ImageSet(from: decoder)) default: if let decodeCustomCardElement = Self.customCardElementDecoders[type] { self = .custom(try decodeCustomCardElement(decoder)) @@ -90,6 +95,9 @@ extension CardElement: Codable { case let .factSet(element): try container.encode(String(describing: FactSet.self), forKey: .type) try element.encode(to: encoder) + case let .imageSet(element): + try container.encode(String(describing: ImageSet.self), forKey: .type) + try element.encode(to: encoder) case let .custom(element): let typeName = type(of: element).typeName guard let encodeCustomCardElement = Self.customCardElementEncoders[typeName] else { @@ -126,6 +134,8 @@ public extension CardElement { return element[keyPath: keyPath] case let .factSet(element): return element[keyPath: keyPath] + case let .imageSet(element): + return element[keyPath: keyPath] case let .custom(element): return element[keyPath: keyPath] case let .unknown(element): @@ -174,6 +184,8 @@ extension CardElement: Equatable { return l == r case let (.factSet(l), .factSet(r)): return l == r + case let (.imageSet(l), .imageSet(r)): + return l == r case let (.custom(l), .custom(r)): let typeName = type(of: l).typeName return Self.customCardElementIsEqual[typeName]?(l, r) ?? false diff --git a/Sources/AdaptiveCardUI/UI/CardElement/CardElementList.swift b/Sources/AdaptiveCardUI/UI/CardElement/CardElementList.swift index dd462b2..fd5db22 100644 --- a/Sources/AdaptiveCardUI/UI/CardElement/CardElementList.swift +++ b/Sources/AdaptiveCardUI/UI/CardElement/CardElementList.swift @@ -62,7 +62,7 @@ private extension CardElement { var bleed: Bool { switch self { - case .textBlock, .image, .richTextBlock, .actionSet, .factSet, .custom: + case .textBlock, .image, .richTextBlock, .actionSet, .factSet, .imageSet, .custom: return false case let .container(element): return element.bleed diff --git a/Sources/AdaptiveCardUI/UI/CardElement/CardElementView.swift b/Sources/AdaptiveCardUI/UI/CardElement/CardElementView.swift index c741a8d..53ee0f3 100644 --- a/Sources/AdaptiveCardUI/UI/CardElement/CardElementView.swift +++ b/Sources/AdaptiveCardUI/UI/CardElement/CardElementView.swift @@ -26,6 +26,8 @@ ColumnSetView(columnSet) case let .factSet(factSet): FactSetView(factSet) + case let .imageSet(imageSet): + ImageSetView(imageSet) case let .custom(customCardElement): CustomCardElementView(customCardElement) default: diff --git a/Sources/AdaptiveCardUI/UI/Helpers/FlowLayout.swift b/Sources/AdaptiveCardUI/UI/Helpers/FlowLayout.swift new file mode 100644 index 0000000..6bf3576 --- /dev/null +++ b/Sources/AdaptiveCardUI/UI/Helpers/FlowLayout.swift @@ -0,0 +1,41 @@ +#if canImport(SwiftUI) + + import SwiftUI + + // Adapted from https://gist.github.com/chriseidhof/3c6ea3fb2102052d1898d8ea27fbee07 + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + struct FlowLayout { + private let proposedSize: CGSize + private let horizontalSpacing: CGFloat + private let verticalSpacing: CGFloat + + private var position = CGPoint.zero + private var lineHeight: CGFloat = 0 + + var size: CGSize { + CGSize(width: proposedSize.width, height: position.y + lineHeight) + } + + init(proposedSize: CGSize, horizontalSpacing: CGFloat, verticalSpacing: CGFloat) { + self.proposedSize = proposedSize + self.horizontalSpacing = horizontalSpacing + self.verticalSpacing = verticalSpacing + } + + mutating func addElementWithSize(_ size: CGSize) -> CGRect { + if position.x + size.width > proposedSize.width { + position.x = 0 + position.y += lineHeight + verticalSpacing + lineHeight = 0 + } + + let result = CGRect(origin: position, size: size) + + lineHeight = max(lineHeight, size.height) + position.x += size.width + horizontalSpacing + + return result + } + } + +#endif diff --git a/Sources/AdaptiveCardUI/UI/Image/ImageSetView.swift b/Sources/AdaptiveCardUI/UI/Image/ImageSetView.swift new file mode 100644 index 0000000..354b47f --- /dev/null +++ b/Sources/AdaptiveCardUI/UI/Image/ImageSetView.swift @@ -0,0 +1,74 @@ +#if canImport(SwiftUI) + + import SwiftUI + + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + struct ImageSetView: View { + @Environment(\.spacingConfiguration) private var spacingConfiguration + + @State private var imageSizes: [String: CGSize] = [:] + @State private var height: CGFloat? + + private let images: [Image] + + init(_ imageSet: ImageSet) { + images = imageSet.images.map { + var result = $0 + result.size = imageSet.supportedImageSize + return result + } + } + + var body: some View { + GeometryReader { proxy in + makeBody(for: proxy.size) + } + .frame(height: height) + .onCollectedSizesChange { collectedSizes in + imageSizes = collectedSizes + } + .onPreferenceChange(ContentSizeKey.self) { contentSize in + height = contentSize?.height + } + } + + private func makeBody(for proposedSize: CGSize) -> some View { + var offsets: [String: CGSize] = [:] + var layout = FlowLayout( + proposedSize: proposedSize, + horizontalSpacing: spacingConfiguration.default, + verticalSpacing: spacingConfiguration.default + ) + + for image in images { + guard let size = imageSizes[image.id] else { + continue + } + let frame = layout.addElementWithSize(size) + offsets[image.id] = CGSize(width: frame.origin.x, height: frame.origin.y) + } + + return ZStack(alignment: .topLeading) { + ForEach(images, id: \.id) { image in + ImageView(image) + .fixedSize() + .collectSize(tag: image.id) + .offset(offsets[image.id] ?? .zero) + } + } + .preference(key: ContentSizeKey.self, value: layout.size) + } + } + + private extension ImageSet { + var supportedImageSize: ImageSize { + switch imageSize { + case .auto, .stretch: + return .medium + default: + return imageSize + } + } + } + +#endif diff --git a/Tests/AdaptiveCardUITests/Logic/AdaptiveCardImageURLsTests.swift b/Tests/AdaptiveCardUITests/Logic/AdaptiveCardImageURLsTests.swift index 247ff8d..c503e4f 100644 --- a/Tests/AdaptiveCardUITests/Logic/AdaptiveCardImageURLsTests.swift +++ b/Tests/AdaptiveCardUITests/Logic/AdaptiveCardImageURLsTests.swift @@ -32,6 +32,11 @@ final class AdaptiveCardImageURLsTests: XCTestCase { ] ) ), + .imageSet( + ImageSet(images: [ + Image(url: URL(string: "https://example.com/image4.png")!), + ]) + ), ], actions: [ .openURL( @@ -69,6 +74,7 @@ final class AdaptiveCardImageURLsTests: XCTestCase { URL(string: "https://example.com/actionIcon1.png")!, URL(string: "https://example.com/image2.png")!, URL(string: "https://example.com/columnBackground.png")!, + URL(string: "https://example.com/image4.png")!, URL(string: "https://example.com/actionIcon2.png")!, URL(string: "https://example.com/image3.png")!, URL(string: "https://example.com/containerBackground.png")!, diff --git a/Tests/AdaptiveCardUITests/Logic/AdaptiveCardToggleVisibilityTests.swift b/Tests/AdaptiveCardUITests/Logic/AdaptiveCardToggleVisibilityTests.swift index 84fea68..57122c0 100644 --- a/Tests/AdaptiveCardUITests/Logic/AdaptiveCardToggleVisibilityTests.swift +++ b/Tests/AdaptiveCardUITests/Logic/AdaptiveCardToggleVisibilityTests.swift @@ -201,4 +201,41 @@ final class AdaptiveCardToggleVisibilityTests: XCTestCase { // then XCTAssertEqual(expected, adaptiveCard) } + + func testImageSetItemToggleVisibilityUpdatesVisibility() { + // given + let targetElement = TargetElement(elementId: "imageToToggle") + var adaptiveCard = AdaptiveCard( + body: [ + .imageSet( + ImageSet( + id: "someImageSet", + images: [ + Image(id: "someId", url: URL(string: "https://example.com/image1.png")!), + Image(id: targetElement.elementId, isVisible: false, url: URL(string: "https://example.com/image2.png")!), + ] + ) + ), + ] + ) + let expected = AdaptiveCard( + body: [ + .imageSet( + ImageSet( + id: "someImageSet", + images: [ + Image(id: "someId", url: URL(string: "https://example.com/image1.png")!), + Image(id: targetElement.elementId, isVisible: true, url: URL(string: "https://example.com/image2.png")!), + ] + ) + ), + ] + ) + + // when + adaptiveCard.toggleVisibility(of: [targetElement]) + + // then + XCTAssertEqual(expected, adaptiveCard) + } } diff --git a/Tests/AdaptiveCardUITests/UI/ImageSetRenderingTests.swift b/Tests/AdaptiveCardUITests/UI/ImageSetRenderingTests.swift new file mode 100644 index 0000000..9f8ed93 --- /dev/null +++ b/Tests/AdaptiveCardUITests/UI/ImageSetRenderingTests.swift @@ -0,0 +1,23 @@ +#if os(iOS) && canImport(SwiftUI) + + import SnapshotTesting + import SwiftUI + import XCTest + + import AdaptiveCardUI + + @available(iOS 14.0, *) + final class ImageSetRenderingTests: XCTestCase { + func testImageSet() { + let view = AdaptiveCardView(url: fixtureURL("imageSet.json")) + .animation(nil) + .adaptiveCardConfiguration(.test) + + let vc = UIHostingController(rootView: view) + vc.view.frame = CGRect(x: 0, y: 0, width: 300, height: 260) + + assertSnapshot(matching: vc, as: .wait(for: 0.25, on: .image)) + } + } + +#endif diff --git a/Tests/AdaptiveCardUITests/UI/__Fixtures__/imageSet.json b/Tests/AdaptiveCardUITests/UI/__Fixtures__/imageSet.json new file mode 100644 index 0000000..0b343b5 --- /dev/null +++ b/Tests/AdaptiveCardUITests/UI/__Fixtures__/imageSet.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "ImageSet", + "imageSize": "small", + "images": [ + { + "type": "Image", + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAKKADAAQAAAABAAAAKAAAAABZLkSWAAABdElEQVRYCc2TCw6CMBBEEbwXeDNv5kE4C4mUMKTAtt0fFBMziu3um5fYNI6vcRyH8HYc2bw9h03T9F3nDV5zW69Bq7l+ntd7WnQDjOw18WerABfAyB543Cy6AFLGqGegl6QZkLCH/S4WzYA5U7nf0KKUJsCMPew1WzQBcgxxzqANlWpAhj3sM1lUA0rMSM6iFVIFKLCHPWqLKkCNEc2d0E4MqLBnsigG1JoIlJq7IkCDPbVFEaDGAMiQ0hlsQAd7YBT9o9mA0uagoVIyiwXoaA+8bIssQEljEJSSO7MIeIE9sLMsFgG5TbFVkpzZWcAL7aFH0WIWkNMQm7RZ2pEEvMEeOmUtJgFLzTDdI3O7SMAb7aFf0iIJmGuEid6Z2nkCrGAPXUmLJ8BUE0y5MqndO8CK9tD7ZHEHSDXAzbvyyLABPsAeHOwsboBHcpyukTHLAvgge/CxWVwAY2KcqJ1gegV785dfbSBqf9d1nxak1IHazwLbH4DVAnPf+eoYAAAAAElFTkSuQmCC" + }, + { + "type": "Image", + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAKKADAAQAAAABAAAAKAAAAABZLkSWAAABdElEQVRYCc2TCw6CMBBEEbwXeDNv5kE4C4mUMKTAtt0fFBMziu3um5fYNI6vcRyH8HYc2bw9h03T9F3nDV5zW69Bq7l+ntd7WnQDjOw18WerABfAyB543Cy6AFLGqGegl6QZkLCH/S4WzYA5U7nf0KKUJsCMPew1WzQBcgxxzqANlWpAhj3sM1lUA0rMSM6iFVIFKLCHPWqLKkCNEc2d0E4MqLBnsigG1JoIlJq7IkCDPbVFEaDGAMiQ0hlsQAd7YBT9o9mA0uagoVIyiwXoaA+8bIssQEljEJSSO7MIeIE9sLMsFgG5TbFVkpzZWcAL7aFH0WIWkNMQm7RZ2pEEvMEeOmUtJgFLzTDdI3O7SMAb7aFf0iIJmGuEid6Z2nkCrGAPXUmLJ8BUE0y5MqndO8CK9tD7ZHEHSDXAzbvyyLABPsAeHOwsboBHcpyukTHLAvgge/CxWVwAY2KcqJ1gegV785dfbSBqf9d1nxak1IHazwLbH4DVAnPf+eoYAAAAAElFTkSuQmCC", + "imageSize": "large" + }, + { + "type": "Image", + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAKKADAAQAAAABAAAAKAAAAABZLkSWAAABdElEQVRYCc2TCw6CMBBEEbwXeDNv5kE4C4mUMKTAtt0fFBMziu3um5fYNI6vcRyH8HYc2bw9h03T9F3nDV5zW69Bq7l+ntd7WnQDjOw18WerABfAyB543Cy6AFLGqGegl6QZkLCH/S4WzYA5U7nf0KKUJsCMPew1WzQBcgxxzqANlWpAhj3sM1lUA0rMSM6iFVIFKLCHPWqLKkCNEc2d0E4MqLBnsigG1JoIlJq7IkCDPbVFEaDGAMiQ0hlsQAd7YBT9o9mA0uagoVIyiwXoaA+8bIssQEljEJSSO7MIeIE9sLMsFgG5TbFVkpzZWcAL7aFH0WIWkNMQm7RZ2pEEvMEeOmUtJgFLzTDdI3O7SMAb7aFf0iIJmGuEid6Z2nkCrGAPXUmLJ8BUE0y5MqndO8CK9tD7ZHEHSDXAzbvyyLABPsAeHOwsboBHcpyukTHLAvgge/CxWVwAY2KcqJ1gegV785dfbSBqf9d1nxak1IHazwLbH4DVAnPf+eoYAAAAAElFTkSuQmCC", + "width": "80px", + "backgroundColor": "#982374" + }, + { + "type": "Image", + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAKKADAAQAAAABAAAAKAAAAABZLkSWAAABdElEQVRYCc2TCw6CMBBEEbwXeDNv5kE4C4mUMKTAtt0fFBMziu3um5fYNI6vcRyH8HYc2bw9h03T9F3nDV5zW69Bq7l+ntd7WnQDjOw18WerABfAyB543Cy6AFLGqGegl6QZkLCH/S4WzYA5U7nf0KKUJsCMPew1WzQBcgxxzqANlWpAhj3sM1lUA0rMSM6iFVIFKLCHPWqLKkCNEc2d0E4MqLBnsigG1JoIlJq7IkCDPbVFEaDGAMiQ0hlsQAd7YBT9o9mA0uagoVIyiwXoaA+8bIssQEljEJSSO7MIeIE9sLMsFgG5TbFVkpzZWcAL7aFH0WIWkNMQm7RZ2pEEvMEeOmUtJgFLzTDdI3O7SMAb7aFf0iIJmGuEid6Z2nkCrGAPXUmLJ8BUE0y5MqndO8CK9tD7ZHEHSDXAzbvyyLABPsAeHOwsboBHcpyukTHLAvgge/CxWVwAY2KcqJ1gegV785dfbSBqf9d1nxak1IHazwLbH4DVAnPf+eoYAAAAAElFTkSuQmCC" + }, + { + "type": "Image", + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAKKADAAQAAAABAAAAKAAAAABZLkSWAAABdElEQVRYCc2TCw6CMBBEEbwXeDNv5kE4C4mUMKTAtt0fFBMziu3um5fYNI6vcRyH8HYc2bw9h03T9F3nDV5zW69Bq7l+ntd7WnQDjOw18WerABfAyB543Cy6AFLGqGegl6QZkLCH/S4WzYA5U7nf0KKUJsCMPew1WzQBcgxxzqANlWpAhj3sM1lUA0rMSM6iFVIFKLCHPWqLKkCNEc2d0E4MqLBnsigG1JoIlJq7IkCDPbVFEaDGAMiQ0hlsQAd7YBT9o9mA0uagoVIyiwXoaA+8bIssQEljEJSSO7MIeIE9sLMsFgG5TbFVkpzZWcAL7aFH0WIWkNMQm7RZ2pEEvMEeOmUtJgFLzTDdI3O7SMAb7aFf0iIJmGuEid6Z2nkCrGAPXUmLJ8BUE0y5MqndO8CK9tD7ZHEHSDXAzbvyyLABPsAeHOwsboBHcpyukTHLAvgge/CxWVwAY2KcqJ1gegV785dfbSBqf9d1nxak1IHazwLbH4DVAnPf+eoYAAAAAElFTkSuQmCC" + }, + { + "type": "Image", + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAKKADAAQAAAABAAAAKAAAAABZLkSWAAABdElEQVRYCc2TCw6CMBBEEbwXeDNv5kE4C4mUMKTAtt0fFBMziu3um5fYNI6vcRyH8HYc2bw9h03T9F3nDV5zW69Bq7l+ntd7WnQDjOw18WerABfAyB543Cy6AFLGqGegl6QZkLCH/S4WzYA5U7nf0KKUJsCMPew1WzQBcgxxzqANlWpAhj3sM1lUA0rMSM6iFVIFKLCHPWqLKkCNEc2d0E4MqLBnsigG1JoIlJq7IkCDPbVFEaDGAMiQ0hlsQAd7YBT9o9mA0uagoVIyiwXoaA+8bIssQEljEJSSO7MIeIE9sLMsFgG5TbFVkpzZWcAL7aFH0WIWkNMQm7RZ2pEEvMEeOmUtJgFLzTDdI3O7SMAb7aFf0iIJmGuEid6Z2nkCrGAPXUmLJ8BUE0y5MqndO8CK9tD7ZHEHSDXAzbvyyLABPsAeHOwsboBHcpyukTHLAvgge/CxWVwAY2KcqJ1gegV785dfbSBqf9d1nxak1IHazwLbH4DVAnPf+eoYAAAAAElFTkSuQmCC" + } + ] + } + ] +} diff --git a/Tests/AdaptiveCardUITests/UI/__Snapshots__/ImageSetRenderingTests/testImageSet.1.png b/Tests/AdaptiveCardUITests/UI/__Snapshots__/ImageSetRenderingTests/testImageSet.1.png new file mode 100644 index 0000000000000000000000000000000000000000..3bf3bf3e083eeb5b481bc791ee9d02e4213e2a23 GIT binary patch literal 14748 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 literal 0 HcmV?d00001