diff --git a/.gitattributes b/.gitattributes index ca3269248..acd096272 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ UITests/Sources/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text DevelopmentAssets/Media/** filter=lfs diff=lfs merge=lfs -text UnitTests/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text -PreviewTests/Sources/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text \ No newline at end of file +PreviewTests/Sources/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text +compound-ios/Tests/CompoundTests/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index e86827ac4..6ddeb74d2 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -1612,6 +1612,7 @@ 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenModels.swift; sourceTree = ""; }; 16D353E10A64172D863769BF /* TombstonedAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TombstonedAvatarImage.swift; sourceTree = ""; }; 1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = ""; }; + 174E4AEF3DED300AA81046EC /* compound-ios */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "compound-ios"; path = "compound-ios"; sourceTree = SOURCE_ROOT; }; 17A8AA0DFA06012A9DAB951E /* TimelineProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProxyMock.swift; sourceTree = ""; }; 17BAE25A0E9E9F2F1BBA8930 /* DeactivateAccountScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeactivateAccountScreenViewModel.swift; sourceTree = ""; }; 181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePreferenceTests.swift; sourceTree = ""; }; @@ -3825,6 +3826,7 @@ A8002CB4F20B6282850A614C /* DevelopmentAssets */, 2197234282B4BC0CE79AAC74 /* Secrets */, 823ED0EC3F1B6CF47D284011 /* Tools */, + 9413F680ECDFB2B0DDB0DEF2 /* Packages */, 681566846AF307E9BA4C72C6 /* Products */, ); sourceTree = ""; @@ -5144,6 +5146,14 @@ path = UserProfileScreen; sourceTree = ""; }; + 9413F680ECDFB2B0DDB0DEF2 /* Packages */ = { + isa = PBXGroup; + children = ( + 174E4AEF3DED300AA81046EC /* compound-ios */, + ); + name = Packages; + sourceTree = ""; + }; 948DD12A5533BE1BC260E437 /* LocationSharing */ = { isa = PBXGroup; children = ( @@ -6871,7 +6881,6 @@ AC3475112CA40C2C6E78D1EB /* XCRemoteSwiftPackageReference "matrix-analytics-events" */, 4A8D3ABF18EABB8066BBD46E /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */, - F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */, 4C34425923978C97409A3EF2 /* XCRemoteSwiftPackageReference "DSWaveformImage" */, C13F55E4518415CB4C278E73 /* XCRemoteSwiftPackageReference "DTCoreText" */, D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */, @@ -6894,6 +6903,7 @@ 6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */, EC6D0C817B1C21D9D096505A /* XCRemoteSwiftPackageReference "Version" */, EE40B0E16A55BD23ECBFFD22 /* XCRemoteSwiftPackageReference "matrix-rich-text-editor-swift" */, + C89CF7729E028671C5DC461E /* XCLocalSwiftPackageReference "compound-ios" */, ); preferredProjectObjectVersion = 77; projectDirPath = ""; @@ -9285,6 +9295,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + C89CF7729E028671C5DC461E /* XCLocalSwiftPackageReference "compound-ios" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "compound-ios"; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 0CBF57301AA172C21F76CE86 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = { isa = XCRemoteSwiftPackageReference; @@ -9486,14 +9503,6 @@ version = 2.37.12; }; }; - F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/element-hq/compound-ios"; - requirement = { - kind = revision; - revision = 16f4544d2823d590401f13da260e56b8674b66b3; - }; - }; F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-collections"; @@ -9512,7 +9521,6 @@ }; 07FEEEDB11543A7DED420F04 /* Compound */ = { isa = XCSwiftPackageProductDependency; - package = F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */; productName = Compound; }; 0DD568A494247444A4B56031 /* Kingfisher */ = { @@ -9577,7 +9585,6 @@ }; 3262F08E1C3483C22A7A319F /* Compound */ = { isa = XCSwiftPackageProductDependency; - package = F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */; productName = Compound; }; 32B8F4CD937AA9C1F8FC3CBC /* KeychainAccess */ = { @@ -9832,7 +9839,6 @@ }; DCA3C4A997AD28E6918D4CE5 /* Compound */ = { isa = XCSwiftPackageProductDependency; - package = F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */; productName = Compound; }; DE8DC9B3FBA402117DC4C49F /* Kingfisher */ = { diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fd615b8b2..d0cda640e 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9d89a9320c29b16ca97c3b4c1dce15f8ab13ae1a66da50c98eec015db72c30d1", + "originHash" : "123c89d756b94a4cfdf4ca86fa4278640fc96854ac223bcef68e5566b0c47018", "pins" : [ { "identity" : "compound-design-tokens", @@ -10,14 +10,6 @@ "version" : "6.0.0" } }, - { - "identity" : "compound-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/element-hq/compound-ios", - "state" : { - "revision" : "16f4544d2823d590401f13da260e56b8674b66b3" - } - }, { "identity" : "devicekit", "kind" : "remoteSourceControl", diff --git a/compound-ios/Package.resolved b/compound-ios/Package.resolved new file mode 100644 index 000000000..7db33c830 --- /dev/null +++ b/compound-ios/Package.resolved @@ -0,0 +1,68 @@ +{ + "pins" : [ + { + "identity" : "compound-design-tokens", + "kind" : "remoteSourceControl", + "location" : "https://github.com/element-hq/compound-design-tokens", + "state" : { + "revision" : "be5d26dfd4ad659b0d3b3aec1ad1cccd0dc8d063", + "version" : "6.0.0" + } + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", + "state" : { + "revision" : "3dd282d3269b061853a3b3bcd23a509d2aa166ce", + "version" : "6.2.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b", + "version" : "1.18.3" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/SwiftUI-Introspect", + "state" : { + "revision" : "a08b87f96b41055577721a6e397562b21ad52454", + "version" : "26.0.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", + "version" : "1.6.1" + } + } + ], + "version" : 2 +} diff --git a/compound-ios/Package.swift b/compound-ios/Package.swift new file mode 100644 index 000000000..0f86f8707 --- /dev/null +++ b/compound-ios/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "Compound", + platforms: [.iOS(.v17)], + products: [ + .library(name: "Compound", targets: ["Compound"]) + ], + dependencies: [ + .package(url: "https://github.com/element-hq/compound-design-tokens", exact: "6.0.0"), + // .package(path: "../compound-design-tokens"), + .package(url: "https://github.com/siteline/SwiftUI-Introspect", from: "26.0.0"), + .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "6.2.0"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", exact: "1.18.3") + ], + targets: [ + .target( + name: "Compound", + dependencies: [ + .product(name: "CompoundDesignTokens", package: "compound-design-tokens"), + .product(name: "SwiftUIIntrospect", package: "SwiftUI-Introspect"), + .product(name: "SFSafeSymbols", package: "SFSafeSymbols") + ] + ), + .testTarget( + name: "CompoundTests", + dependencies: [ + "Compound", + .product(name: "SnapshotTesting", package: "swift-snapshot-testing") + ], + exclude: [ + "__Snapshots__" + ] + ) + ] +) diff --git a/compound-ios/Sources/Compound/BaseStyles/CompoundButtonStyle.swift b/compound-ios/Sources/Compound/BaseStyles/CompoundButtonStyle.swift new file mode 100644 index 000000000..d8d46d02c --- /dev/null +++ b/compound-ios/Sources/Compound/BaseStyles/CompoundButtonStyle.swift @@ -0,0 +1,312 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +public extension ButtonStyle where Self == CompoundButtonStyle { + /// A button style that applies Compound design tokens to a button with various configuration options. + /// - Parameter kind: The kind of button being shown such as primary or secondary. + /// - Parameter size: The button size to use. Defaults to `large`. + static func compound(_ kind: Self.Kind, size: Self.Size = .large) -> CompoundButtonStyle { + CompoundButtonStyle(kind: kind, size: size) + } +} + +/// Default button style for standalone buttons. +public struct CompoundButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityShowButtonShapes) private var accessibilityShowButtonShapes + + var kind: Kind + public enum Kind { + /// A stroked button that uses colour to highlight important actions. + case `super` + /// A filled button usually representing the default action. + case primary + /// A stroked button usually representing alternate actions. + case secondary + /// A plain button with matching dimensions to ``primary`` and ``secondary``. + case tertiary + /// A plain button with no padding. + case textLink + } + + var size: Size + public enum Size { + /// A button that is prominently sized. + case large + /// A button that is a regular size. + case medium + /// A button that is a small size. + case small + /// A (super/primary/secondary) button that should be place within a toolbar. + case toolbarIcon + } + + private var font: Font { + if kind == .textLink, size == .small { + .compound.bodyMDSemibold + } else { + .compound.bodyLGSemibold + } + } + + private var horizontalPadding: CGFloat { + if kind == .textLink { + return 0 + } + + return switch size { + case .large: 20 + case .medium: 20 + case .small: 16 + case .toolbarIcon: 3 + } + } + + private var verticalPadding: CGFloat { + if kind == .textLink { + return 0 + } + + return switch size { + case .large: 14 + case .medium: 7 + case .small: 4 + case .toolbarIcon: 3 + } + } + + private var maxWidth: CGFloat? { + if kind == .textLink { + return nil + } + + return switch size { + case .large: .infinity + case .medium: nil + case .small: nil + case .toolbarIcon: nil + } + } + + private var pressedOpacity: Double { + colorScheme == .light ? 0.3 : 0.6 + } + + private var isUnderlined: Bool { + kind == .textLink && accessibilityShowButtonShapes + } + + public func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .font(font) + .underline(isUnderlined) + .multilineTextAlignment(.center) + .foregroundColor(textColor(configuration: configuration)) + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .frame(maxWidth: maxWidth) + .background { + makeBackground(configuration: configuration) + } + .contentShape(contentShape) + } + + @ViewBuilder + private func makeBackground(configuration: Self.Configuration) -> some View { + switch kind { + case .super: + if isEnabled { + ZStack { + Capsule().fill(.compound.bgCanvasDefault) + Capsule().fill(LinearGradient(gradient: .compound.action, + startPoint: .top, endPoint: .bottom)) + .opacity(0.04) + Capsule().strokeBorder(LinearGradient(gradient: .compound.action, + startPoint: .top, endPoint: .bottom)) + } + .compositingGroup() + .opacity(configuration.isPressed ? pressedOpacity : 1) + } else { + Capsule().strokeBorder(strokeColor(configuration: configuration)) + } + case .primary: + Capsule().fill(fillColor(configuration: configuration)) + case .secondary: + Capsule().strokeBorder(strokeColor(configuration: configuration)) + case .tertiary: + EmptyView() + case .textLink: + EmptyView() + } + } + + private var contentShape: AnyShape { + switch kind { + case .super, .primary, .secondary, .tertiary: + return AnyShape(Capsule()) + case .textLink: + return AnyShape(Rectangle()) + } + } + + private func fillColor(configuration: Self.Configuration) -> Color { + guard isEnabled else { return .compound.bgActionPrimaryDisabled } + if configuration.role == .destructive { + return .compound.bgCriticalPrimary.opacity(configuration.isPressed ? pressedOpacity : 1) + } else { + return configuration.isPressed ? .compound.bgActionPrimaryPressed : .compound.bgActionPrimaryRest + } + } + + private func strokeColor(configuration: Self.Configuration) -> Color { + if configuration.role == .destructive { + return .compound.borderCriticalPrimary.opacity(configuration.isPressed ? pressedOpacity : 1) + } else { + return .compound.borderInteractiveSecondary.opacity(configuration.isPressed ? pressedOpacity : 1) + } + } + + private func textColor(configuration: Configuration) -> Color { + if kind == .primary { + return .compound.textOnSolidPrimary + } else { + guard isEnabled else { return .compound.textDisabled } + let textColor: Color = configuration.role == .destructive ? .compound.textCriticalPrimary : .compound.textActionPrimary + return textColor.opacity(configuration.isPressed ? pressedOpacity : 1) + } + } +} + +// MARK: - Previews + +public struct CompoundButtonStyle_Previews: PreviewProvider, TestablePreview { + public static var previews: some View { + ScrollView { + states + } + .previewLayout(.fixed(width: 390, height: 1875)) + } + + @ViewBuilder + public static var states: some View { + Section { + buttons(.large) + } header: { + Header(title: "Large") + } + + Section { + buttons(.medium) + } header: { + Header(title: "Medium") + } + + Section { + buttons(.small) + } header: { + Header(title: "Small") + } + + Section { + textLinks(.medium) + } header: { + Header(title: "Text Link") + } + + Section { + textLinks(.small) + } header: { + Header(title: "Text Link Small") + } + + Section { + startChat + .padding(.bottom) // Only for the snapshot. + } header: { + Header(title: "Start chat") + } + } + + public static func buttons(_ size: CompoundButtonStyle.Size) -> some View { + VStack { + Button("Super") { } + .buttonStyle(.compound(.super, size: size)) + + Button("Disabled") { } + .buttonStyle(.compound(.super, size: size)) + .disabled(true) + + Button("Primary") { } + .buttonStyle(.compound(.primary, size: size)) + + Button("Destructive", role: .destructive) { } + .buttonStyle(.compound(.primary, size: size)) + + Button("Disabled") { } + .buttonStyle(.compound(.primary, size: size)) + .disabled(true) + + Button("Secondary") { } + .buttonStyle(.compound(.secondary, size: size)) + + Button("Destructive", role: .destructive) { } + .buttonStyle(.compound(.secondary, size: size)) + + Button("Disabled") { } + .buttonStyle(.compound(.secondary, size: size)) + .disabled(true) + + Button("Tertiary") { } + .buttonStyle(.compound(.tertiary, size: size)) + + Button("Destructive", role: .destructive) { } + .buttonStyle(.compound(.tertiary, size: size)) + + Button("Disabled") { } + .buttonStyle(.compound(.tertiary, size: size)) + .disabled(true) + } + .padding(.horizontal) + } + + static func textLinks(_ size: CompoundButtonStyle.Size) -> some View { + HStack(spacing: 20) { + Button("Text Link") { } + .buttonStyle(.compound(.textLink, size: size)) + + Button("Destructive", role: .destructive) { } + .buttonStyle(.compound(.textLink, size: size)) + + Button("Disabled") { } + .buttonStyle(.compound(.textLink, size: size)) + .disabled(true) + } + .padding(.top, 1) + } + + static var startChat: some View { + Button { } label: { + CompoundIcon(\.plus) + } + .buttonStyle(.compound(.super, size: .toolbarIcon)) + } + + struct Header: View { + let title: String + + var body: some View { + Text(title) + .foregroundStyle(.compound.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding([.leading, .top]) + .padding(.leading ) + } + } +} diff --git a/compound-ios/Sources/Compound/BaseStyles/CompoundTextFieldStyles.swift b/compound-ios/Sources/Compound/BaseStyles/CompoundTextFieldStyles.swift new file mode 100644 index 000000000..694a35164 --- /dev/null +++ b/compound-ios/Sources/Compound/BaseStyles/CompoundTextFieldStyles.swift @@ -0,0 +1,16 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +public extension Text { + /// Styles a text with the Compound design tokens to be displayed as a text field placeholder. + func compoundTextFieldPlaceholder() -> Text { + font(.compound.bodyLG) + .foregroundColor(.compound.textSecondary) + } +} diff --git a/compound-ios/Sources/Compound/BaseStyles/CompoundToggleStyle.swift b/compound-ios/Sources/Compound/BaseStyles/CompoundToggleStyle.swift new file mode 100644 index 000000000..40283d0fc --- /dev/null +++ b/compound-ios/Sources/Compound/BaseStyles/CompoundToggleStyle.swift @@ -0,0 +1,67 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +public extension ToggleStyle where Self == CompoundToggleStyle { + /// A toggle style that applies Compound design tokens to display a Switch row within a `Form`. + static var compound: CompoundToggleStyle { + CompoundToggleStyle() + } +} + +/// Default toggle styling for form rows. +/// +/// The toggle is given the form row label style and is tinted correctly. +public struct CompoundToggleStyle: ToggleStyle { + public func makeBody(configuration: Configuration) -> some View { + Toggle(isOn: configuration.$isOn) { + configuration.label + .foregroundColor(.compound.textPrimary) + } + .tint(.compound.iconAccentTertiary) + } +} + +public struct CompoundToggleStyle_Previews: PreviewProvider, TestablePreview { + public static var previews: some View { + VStack(spacing: 16) { + states + } + .padding(32) + } + + @ViewBuilder + public static var states: some View { + VStack(spacing: 16) { + Toggle("Title", isOn: .constant(false)) + .toggleStyle(.compound) + .labelsHidden() + + Toggle("Title", isOn: .constant(true)) + .toggleStyle(.compound) + .labelsHidden() + } + .padding(.bottom, 32) + + VStack(spacing: 16) { + Toggle("Title", isOn: .constant(true)) + .toggleStyle(.compound) + Toggle("Title", isOn: .constant(false)) + .toggleStyle(.compound) + + Toggle(isOn: .constant(true)) { + Label("Title", systemImage: "square.dashed") + } + .toggleStyle(.compound) + Toggle(isOn: .constant(false)) { + Label("Title", systemImage: "square.dashed") + } + .toggleStyle(.compound) + } + } +} diff --git a/compound-ios/Sources/Compound/Buttons/SendButton.swift b/compound-ios/Sources/Compound/Buttons/SendButton.swift new file mode 100644 index 000000000..043486612 --- /dev/null +++ b/compound-ios/Sources/Compound/Buttons/SendButton.swift @@ -0,0 +1,69 @@ +// +// Copyright 2024 New Vector Ltd +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +/// The button component for sending messages and media. +public struct SendButton: View { + @Environment(\.isEnabled) private var isEnabled + @Environment(\.colorScheme) private var colorScheme + + /// The action to perform when the user triggers the button. + public let action: () -> Void + + private var iconColor: Color { + guard isEnabled else { return .compound.iconQuaternary } + return colorScheme == .light ? .compound.iconOnSolidPrimary : .compound.iconPrimary + } + + private var gradient: Gradient { isEnabled ? .compound.action : .init(colors: [.clear]) } + + /// Creates a send button that performs the provided action. + public init(action: @escaping () -> Void) { + self.action = action + } + + public var body: some View { + Button(action: action) { + CompoundIcon(\.sendSolid, size: .medium, relativeTo: .compound.headingLG) + .foregroundStyle(iconColor) + .scaledPadding(6, relativeTo: .compound.headingLG) + .background { buttonShape } + .compositingGroup() + } + } + + var buttonShape: some View { + Circle() + .fill(LinearGradient(gradient: gradient, startPoint: .top, endPoint: .bottom)) + } +} + +// MARK: - Previews + +public struct SendButton_Previews: PreviewProvider, TestablePreview { + public static var previews: some View { + VStack(spacing: 0) { + states + .padding(20) + .background(.compound.bgCanvasDefault) + states + .padding(20) + .background(.compound.bgCanvasDefault) + .environment(\.colorScheme, .dark) + } + .cornerRadius(20) + } + + public static var states: some View { + HStack(spacing: 30) { + SendButton { } + .disabled(true) + SendButton { } + } + } +} diff --git a/compound-ios/Sources/Compound/Colors/CompoundColors.swift b/compound-ios/Sources/Compound/Colors/CompoundColors.swift new file mode 100644 index 000000000..985ae43a2 --- /dev/null +++ b/compound-ios/Sources/Compound/Colors/CompoundColors.swift @@ -0,0 +1,115 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import CompoundDesignTokens +import SwiftUI + +public extension Color { + /// The colours used by Element as defined in Compound Design Tokens. + static let compound = CompoundColors() +} + +public extension ShapeStyle where Self == Color { + /// The colours used by Element as defined in Compound Design Tokens. + static var compound: CompoundColors { Self.compound } +} + +/// The colours used by Element as defined in Compound Design Tokens. +/// This struct contains only the colour tokens in a more usable form. +@dynamicMemberLookup +public class CompoundColors { + /// The base colour tokens that form the palette of available colours. + /// + /// Normally these shouldn't be necessary, however in practice we may need + /// access for temporary tokens while waiting for official ones to be formalised. + private static let coreTokens = CompoundCoreColorTokens.self + /// The main semantic tokens generated from the Style Dictionary. + private let tokens: CompoundColorTokens + /// Runtime overrides for the `tokens` property. + private var overrides = [KeyPath: Color]() + + public subscript(dynamicMember keyPath: KeyPath) -> Color { + return overrides[keyPath] ?? tokens[keyPath: keyPath] + } + + /// Customise the colour at the specified key path with the supplied colour. + /// Supplying `nil` as the colour will remove any existing customisation. + public func override(_ keyPath: KeyPath, with color: Color?) { + overrides[keyPath] = color + } + + init() { + let tokens = CompoundColorTokens() + self.tokens = tokens + + decorativeColors = [ + .init(background: tokens.bgDecorative1, text: tokens.textDecorative1), + .init(background: tokens.bgDecorative2, text: tokens.textDecorative2), + .init(background: tokens.bgDecorative3, text: tokens.textDecorative3), + .init(background: tokens.bgDecorative4, text: tokens.textDecorative4), + .init(background: tokens.bgDecorative5, text: tokens.textDecorative5), + .init(background: tokens.bgDecorative6, text: tokens.textDecorative6), + ] + } + + // MARK: - Decorative Colors + // Used to determine the background and text colors of avatars, usernames etc. + + let decorativeColors: [DecorativeColor] + + public func decorativeColor(for contentID: String) -> DecorativeColor { + decorativeColors[contentID.hashCode] + } + + // MARK: - Awaiting Semantic Tokens + + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _borderTextFieldFocused = coreTokens.gray500 + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _bgBubbleIncoming = Color(UIColor { $0.isLight ? UIColor(coreTokens.gray300) : UIColor(coreTokens.gray400) }) + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _bgBubbleOutgoing = Color(UIColor { $0.isLight ? UIColor(coreTokens.gray400) : UIColor(coreTokens.gray500) }) + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _bgCodeBlock = coreTokens.gray100 + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _borderInteractiveSecondaryAlpha = coreTokens.alphaGray600 + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _bgSubtleSecondaryAlpha = coreTokens.alphaGray300 + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _bgCriticalSubtleAlpha = coreTokens.alphaRed300 + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _bgEmptyItemAlpha = coreTokens.alphaGray500 +} + +private extension UITraitCollection { + /// Whether or not the trait collection contains a `userInterfaceStyle` of `.light`. + var isLight: Bool { userInterfaceStyle == .light } +} + +public struct DecorativeColor: Equatable { + public let background: Color + public let text: Color +} + +private extension String { + /// Calculates a numeric hash same as Element Web + /// See original function here https://github.com/matrix-org/matrix-react-sdk/blob/321dd49db4fbe360fc2ff109ac117305c955b061/src/utils/FormattingUtils.js#L47 + var hashCode: Int { + let characterCodeSum = self.reduce(0) { sum, character in + sum + Int(character.unicodeScalars.first?.value ?? 0) + } + return (characterCodeSum % Color.compound.decorativeColors.count) + } +} diff --git a/compound-ios/Sources/Compound/Colors/CompoundGradients.swift b/compound-ios/Sources/Compound/Colors/CompoundGradients.swift new file mode 100644 index 000000000..283a9e1f0 --- /dev/null +++ b/compound-ios/Sources/Compound/Colors/CompoundGradients.swift @@ -0,0 +1,37 @@ +// +// Copyright 2025 New Vector Ltd +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import CompoundDesignTokens +import SwiftUI + +public extension Gradient { + /// The gradients used by Element as defined in Compound Design Tokens. + static let compound = CompoundGradients() +} + +/// The gradients used by Element as defined in Compound Design Tokens. +/// This struct only contains the gradients assembled from the individual colour stops. +public struct CompoundGradients { + // We need to use computed properties here so that the gradients include the + // latest token overrides that have been applied since the struct was created. + public var action: Gradient { .init(colors: [.compound.gradientActionStop1, + .compound.gradientActionStop2, + .compound.gradientActionStop3, + .compound.gradientActionStop4]) } + public var subtle: Gradient { .init(colors: [.compound.gradientSubtleStop1, + .compound.gradientSubtleStop2, + .compound.gradientSubtleStop3, + .compound.gradientSubtleStop4, + .compound.gradientSubtleStop5, + .compound.gradientSubtleStop6]) } + public var info: Gradient { .init(colors: [.compound.gradientInfoStop1, + .compound.gradientInfoStop2, + .compound.gradientInfoStop3, + .compound.gradientInfoStop4, + .compound.gradientInfoStop5, + .compound.gradientInfoStop6]) } +} diff --git a/compound-ios/Sources/Compound/Colors/CompoundUIColors.swift b/compound-ios/Sources/Compound/Colors/CompoundUIColors.swift new file mode 100644 index 000000000..6ecae1423 --- /dev/null +++ b/compound-ios/Sources/Compound/Colors/CompoundUIColors.swift @@ -0,0 +1,53 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import CompoundDesignTokens +import UIKit + +public extension UIColor { + /// The colours used by Element as defined in Compound Design Tokens. + static let compound = CompoundUIColors() +} + +/// The colours used by Element as defined in Compound Design Tokens. +/// This struct contains only the colour tokens in a more usable form. +@dynamicMemberLookup +public class CompoundUIColors { + /// The base colour tokens that form the palette of available colours. + /// + /// Normally these shouldn't be necessary, however in practice we may need + /// access for temporary tokens while waiting for official ones to be formalised. + private static let coreTokens = CompoundCoreUIColorTokens.self + /// The main semantic tokens generated from the Style Dictionary. + private let tokens = CompoundUIColorTokens() + /// Runtime overrides for the `tokens` property. + private var overrides = [KeyPath: UIColor]() + + public subscript(dynamicMember keyPath: KeyPath) -> UIColor { + return overrides[keyPath] ?? tokens[keyPath: keyPath] + } + + /// Customise the colour at the specified key path with the supplied colour. + /// Supplying `nil` as the colour will remove any existing customisation. + public func override(_ keyPath: KeyPath, with color: UIColor?) { + overrides[keyPath] = color + } + + // MARK: - Awaiting Semantic Tokens + + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _bgCodeBlock = coreTokens.gray100 + /// This token is a placeholder and hasn't been finalised. + @available(iOS, deprecated: 100000.0, message: "This token should be generated by now.") + public let _bgSubtleSecondaryAlpha = coreTokens.alphaGray300 +} + +private extension UITraitCollection { + /// Whether or not the trait collection contains a `userInterfaceStyle` of `.light`. + var isLight: Bool { userInterfaceStyle == .light } +} diff --git a/compound-ios/Sources/Compound/Extensions/PlatformVersionPredicate.swift b/compound-ios/Sources/Compound/Extensions/PlatformVersionPredicate.swift new file mode 100644 index 000000000..91b0b0dec --- /dev/null +++ b/compound-ios/Sources/Compound/Extensions/PlatformVersionPredicate.swift @@ -0,0 +1,45 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI +@_spi(Advanced) import SwiftUIIntrospect + +public extension PlatformViewVersionPredicate { + static var supportedVersions: Self { + .iOS(.v17...) + } +} + +public extension PlatformViewVersionPredicate { + static var supportedVersions: Self { + .iOS(.v17...) + } +} + +public extension PlatformViewVersionPredicate { + static var supportedVersions: Self { + .iOS(.v17...) + } +} + +public extension PlatformViewVersionPredicate { + static var supportedVersions: Self { + .iOS(.v17...) + } +} + +public extension PlatformViewVersionPredicate { + static var supportedVersions: Self { + .iOS(.v17...) + } +} + +public extension PlatformViewVersionPredicate { + static var supportedVersions: Self { + .iOS(.v17...) + } +} diff --git a/compound-ios/Sources/Compound/Fonts/CompoundFonts.swift b/compound-ios/Sources/Compound/Fonts/CompoundFonts.swift new file mode 100644 index 000000000..442d7357b --- /dev/null +++ b/compound-ios/Sources/Compound/Fonts/CompoundFonts.swift @@ -0,0 +1,52 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +public extension Font { + /// The fonts used by Element as defined in Compound Design Tokens. + static let compound = CompoundFonts() +} + +/// A manual mapping of the Compound font styles to iOS styles. This will be +/// generated directly from the style dictionary in the future. +public struct CompoundFonts { + public let bodyXS = Font.caption + public let bodyXSSemibold = Font.caption.weight(.semibold) + public let bodySM = Font.footnote + public let bodySMSemibold = Font.footnote.weight(.semibold) + public let bodyMD = Font.subheadline + public let bodyMDSemibold = Font.subheadline.weight(.semibold) + public let bodyLG = Font.body + public let bodyLGSemibold = Font.body.weight(.semibold) + public let headingSM = Font.title3 + public let headingSMSemibold = Font.title3.weight(.semibold) + public let headingMD = Font.title2 + public let headingMDBold = Font.title2.bold() + public let headingLG = Font.title + public let headingLGBold = Font.title.bold() + public let headingXL = Font.largeTitle + public let headingXLBold = Font.largeTitle.bold() +} + +public extension Font.TextStyle { + /// The text styles used by Element as defined in Compound Design Tokens. + static let compound = CompoundTextStyles() +} + +/// A manual mapping of the Compound font styles to iOS text styles. This is useful +/// for `@ScaledMetric` along with modifiers such as `scaledPadding` etc. +public struct CompoundTextStyles { + public let bodyXS = Font.TextStyle.caption + public let bodySM = Font.TextStyle.footnote + public let bodyMD = Font.TextStyle.subheadline + public let bodyLG = Font.TextStyle.body + public let headingSM = Font.TextStyle.title3 + public let headingMD = Font.TextStyle.title2 + public let headingLG = Font.TextStyle.title + public let headingXL = Font.TextStyle.largeTitle +} diff --git a/compound-ios/Sources/Compound/Fonts/FontSize.swift b/compound-ios/Sources/Compound/Fonts/FontSize.swift new file mode 100644 index 000000000..f928e37c0 --- /dev/null +++ b/compound-ios/Sources/Compound/Fonts/FontSize.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// The size of a SwiftUI font. +enum FontSize { + case custom(CGFloat, Font.TextStyle?) + case style(Font.TextStyle) + + /// The raw value in points. + var value: CGFloat { + switch self { + case .custom(let size, _): + return size + case .style(let style): + switch style { + case .largeTitle: + return UIFont.preferredFont(forTextStyle: .largeTitle).pointSize + case .title: + return UIFont.preferredFont(forTextStyle: .title1).pointSize + case .title2: + return UIFont.preferredFont(forTextStyle: .title2).pointSize + case .title3: + return UIFont.preferredFont(forTextStyle: .title3).pointSize + case .body: + return UIFont.preferredFont(forTextStyle: .body).pointSize + case .headline: + return UIFont.preferredFont(forTextStyle: .headline).pointSize + case .callout: + return UIFont.preferredFont(forTextStyle: .callout).pointSize + case .subheadline: + return UIFont.preferredFont(forTextStyle: .subheadline).pointSize + case .footnote: + return UIFont.preferredFont(forTextStyle: .footnote).pointSize + case .caption: + return UIFont.preferredFont(forTextStyle: .caption1).pointSize + case .caption2: + return UIFont.preferredFont(forTextStyle: .caption2).pointSize + @unknown default: + return UIFont.preferredFont(forTextStyle: .body).pointSize + } + } + } + + /// The text style of the font. + var style: Font.TextStyle { + switch self { + case .custom(_, let textStyle): + return textStyle ?? .body + case .style(let textStyle): + return textStyle + } + } + + static func reflecting(_ font: Font) -> FontSize? { + let mirror = Mirror(reflecting: font) + guard let provider = mirror.descendant("provider", "base") else { return nil } + return resolveFontSize(provider) + } + + private static func resolveFontSize(_ provider: Any) -> FontSize? { + let mirror = Mirror(reflecting: provider) + + if let size = mirror.descendant("size") as? CGFloat { + return .custom(size, mirror.descendant("textStyle") as? Font.TextStyle) + } else if let textStyle = mirror.descendant("style") as? Font.TextStyle { + return .style(textStyle) + } + + // recurse to handle modifiers. + guard let provider = mirror.descendant("base", "provider", "base") else { return nil } + return resolveFontSize(provider) + } +} diff --git a/compound-ios/Sources/Compound/Icons/CompoundIcon.swift b/compound-ios/Sources/Compound/Icons/CompoundIcon.swift new file mode 100644 index 000000000..8577e0078 --- /dev/null +++ b/compound-ios/Sources/Compound/Icons/CompoundIcon.swift @@ -0,0 +1,250 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +@_exported import CompoundDesignTokens +import SwiftUI + +public extension Image { + /// The icons used by Element as defined in Compound Design Tokens. + static let compound = CompoundIcons() +} + +/// A view that displays an icon from Compound. The icon defaults to a size of 24pt +/// and scales with Dynamic Type, relative to any font given to it by the `font` modifier. +public struct CompoundIcon: View { + /// The size of the icon. + public enum Size { + /// An icon size of 16pt. + case xSmall + /// An icon size of 20pt. + case small + /// An icon size of 24pt. + case medium + /// A custom icon size. + case custom(CGFloat) + + var value: CGFloat { + switch self { + case .xSmall: return 16 + case .small: return 20 + case .medium: return 24 + case .custom(let size): return size + } + } + } + + private var image: Image + private var size: Size + private var font: Font + + private var fontSize: FontSize { + FontSize.reflecting(font) ?? .style(.body) + } + + /// Creates an icon using a key path from the Compound tokens. The size will be + /// 24pt and will scale relative to the `bodyLG` font when Dynamic Type is used. + /// + /// - Parameters: + /// - icon: The icon to show. + public init(_ icon: KeyPath) { + image = .compound[keyPath: icon] + self.size = .medium + self.font = .compound.bodyLG + } + + /// Creates an icon using a key path from the Compound tokens. + /// + /// - Parameters: + /// - icon: The icon to show. + /// - size: The size of the icon. + /// - font: The font that should be used for scaling with Dynamic Type. + public init(_ icon: KeyPath, size: Size, relativeTo font: Font) { + image = .compound[keyPath: icon] + self.size = size + self.font = font + } + + /// Creates an icon using a custom image to allow assets from outside + /// of Compound to scale in the same way as icons. The size will be 24pt + /// and will scale relative to the `bodyLG` font when Dynamic Type is used. + /// + /// - Parameters: + /// - customImage: The image that should be displayed + /// + /// ** Note:** The image should have a square frame or it may end up distorted. + public init(customImage: Image) { + image = customImage + self.size = .medium + self.font = .compound.bodyLG + } + + /// Creates an icon using a custom image to allow assets from outside + /// of Compound to scale in the same way as icons. + /// + /// - Parameters: + /// - customImage: The image that should be displayed + /// - size: The size of the icon. + /// - font: The font that should be used for scaling with Dynamic Type. + /// + /// ** Note:** The image should have a square frame or it may end up distorted. + public init(customImage: Image, size: Size, relativeTo font: Font) { + image = customImage + self.size = size + self.font = font + } + + public var body: some View { + image + .resizable() + .modifier(CompoundIconFrame(fontSize: size.value, textStyle: fontSize.style)) + } +} + +/// A simple modifier that applies a square frame of a given size that will be +/// scaled dynamically based upon the specified text style. +private struct CompoundIconFrame: ViewModifier { + @ScaledMetric private var size: CGFloat + + init(fontSize: CGFloat, textStyle: Font.TextStyle) { + _size = ScaledMetric(wrappedValue: fontSize, relativeTo: textStyle) + } + + func body(content: Content) -> some View { + content + .frame(width: size, height: size) + } +} + +public extension Label { + /// Creates a label with an icon from Compound and a title generated from a string. + /// The icon size will be 24pt, scaling relative to the `bodyLG` with Dynamic Type. + /// - Parameters: + /// - title: A string used as the label’s title. + /// - icon: The icon to use from Compound. + init(_ title: some StringProtocol, icon: KeyPath) where Title == Text, Icon == CompoundIcon { + self.init { + Text(title) + } icon: { + CompoundIcon(icon) + } + } + + /// Creates a label with an icon from Compound and a title generated from a string. + /// - Parameters: + /// - title: A string used as the label’s title. + /// - icon: The icon to use from Compound. + /// - iconSize: The size of the icon. + /// - font: The font that the icon should scale relative to with Dynamic Type. + init(_ title: some StringProtocol, + icon: KeyPath, + iconSize: CompoundIcon.Size, + relativeTo font: Font) where Title == Text, Icon == CompoundIcon { + self.init { + Text(title) + } icon: { + CompoundIcon(icon, size: iconSize, relativeTo: font) + } + } +} + +// MARK: - Previews + +struct CompoundIcon_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + form + .previewDisplayName("Form") + buttons + .padding(8) + .previewLayout(.sizeThatFits) + .previewDisplayName("Buttons") + accessibilityIcons + .previewDisplayName("Accessibility Icons Only") + accessibilityLabels + .previewDisplayName("Accessibility Labels") + } + + static var accessibilityIcons: some View { + VStack { + ForEach(DynamicTypeSize.allCases, id: \.self) { size in + HStack { + CompoundIcon(\.userProfile, size: .xSmall, relativeTo: .compound.bodyXS) + CompoundIcon(\.userProfile, size: .small, relativeTo: .compound.bodySM) + CompoundIcon(\.userProfile, size: .medium, relativeTo: .compound.bodyLG) + } + .dynamicTypeSize(size) + } + } + } + + + static var accessibilityLabels: some View { + Grid(alignment: .leading) { + ForEach(DynamicTypeSize.allCases, id: \.self) { + size in + GridRow { + Label("Test XS", icon: \.userProfile, iconSize: .xSmall, relativeTo: .compound.bodyXS) + .font(.compound.bodyXS) + Label("Test Small", icon: \.userProfile, iconSize: .small, relativeTo: .compound.bodySM) + .font(.compound.bodySM) + Label("Test Medium", icon: \.userProfile, iconSize: .medium, relativeTo: .compound.bodyLG) + .font(.compound.bodyLG) + } + .lineLimit(1) + .dynamicTypeSize(size) + } + } + } + + static var form: some View { + Form { + Section { + ListRow(label: .action(title: "Plain Icon", icon: \.userProfile), + kind: .label) + ListRow(label: .default(title: "Plain Icon", icon: \.userProfile), + kind: .label) + ListRow(label: .default(title: "Plain Icon", systemIcon: .personCropCircle), + kind: .label) + } + } + .compoundList() + .safeAreaInset(edge: .bottom) { + Button { } label: { + Label("Button", icon: \.userProfile) + } + .buttonStyle(.compound(.primary)) + .padding() + } + } + + static var buttons: some View { + VStack { + Button { } label: { + Label { Text("Body Large") } icon: { + CompoundIcon(\.userProfile, size: .medium, relativeTo: .compound.bodyLG) + } + } + .font(.compound.bodyLG) + .buttonStyle(.borderedProminent) + + Button { } label: { + Label { Text("Body Small") } icon: { + CompoundIcon(\.userProfile, size: .small, relativeTo: .compound.bodySM) + } + } + .font(.compound.bodySM) + .buttonStyle(.borderedProminent) + + Button { } label: { + Label { Text("Body xSmall") } icon: { + CompoundIcon(\.userProfile, size: .xSmall, relativeTo: .compound.bodyXS) + } + } + .font(.compound.bodyXS) + .buttonStyle(.borderedProminent) + } + } +} diff --git a/compound-ios/Sources/Compound/Layout/ScaledFrameModifier.swift b/compound-ios/Sources/Compound/Layout/ScaledFrameModifier.swift new file mode 100644 index 000000000..15b7ab35d --- /dev/null +++ b/compound-ios/Sources/Compound/Layout/ScaledFrameModifier.swift @@ -0,0 +1,56 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +public extension View { + /// Positions this view within an invisible frame with a square size that scales relative to the user's selected font size. + func scaledFrame(size: CGFloat, alignment: Alignment = .center, relativeTo textStyle: Font.TextStyle = .body) -> some View { + scaledFrame(width: size, height: size, alignment: alignment, relativeTo: textStyle) + } + + /// Positions this view within an invisible frame with a size that scales relative to the user's selected font size. + func scaledFrame(width: CGFloat, height: CGFloat, alignment: Alignment = .center, relativeTo textStyle: Font.TextStyle = .body) -> some View { + modifier(ScaledFrameModifier(width: width, height: height, alignment: alignment, relativeTo: textStyle)) + } +} + +private struct ScaledFrameModifier: ViewModifier { + @ScaledMetric var width: CGFloat + @ScaledMetric var height: CGFloat + let alignment: Alignment + + init(width: CGFloat, height: CGFloat, alignment: Alignment, relativeTo textStyle: Font.TextStyle) { + _width = ScaledMetric(wrappedValue: width, relativeTo: textStyle) + _height = ScaledMetric(wrappedValue: height, relativeTo: textStyle) + self.alignment = alignment + } + + func body(content: Content) -> some View { + content.frame(width: width, height: height, alignment: alignment) + } +} + +// MARK: - Previews + +struct ScaledFrameModifier_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VStack { + ForEach(DynamicTypeSize.allCases, id: \.self) { size in + likeButtonLabel + .dynamicTypeSize(size) + } + } + } + + static var likeButtonLabel: some View { + Image(systemSymbol: .heartCircleFill) + .resizable() + .foregroundStyle(.compound.iconAccentTertiary) + .scaledFrame(size: 24, relativeTo: .title) + } +} diff --git a/compound-ios/Sources/Compound/Layout/ScaledOffsetModifier.swift b/compound-ios/Sources/Compound/Layout/ScaledOffsetModifier.swift new file mode 100644 index 000000000..7ad5fd27d --- /dev/null +++ b/compound-ios/Sources/Compound/Layout/ScaledOffsetModifier.swift @@ -0,0 +1,53 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +public extension View { + /// Offset this view by the specified horizontal and vertical distances, scaled relative to the user's selected font size. + func scaledOffset(x: CGFloat = 0, y: CGFloat = 0, relativeTo textStyle: Font.TextStyle = .body) -> some View { + modifier(ScaledOffsetModifier(x: x, y: y, relativeTo: textStyle)) + } +} + +private struct ScaledOffsetModifier: ViewModifier { + @ScaledMetric var x: CGFloat + @ScaledMetric var y: CGFloat + + init(x: CGFloat, y: CGFloat, relativeTo textStyle: Font.TextStyle) { + _x = ScaledMetric(wrappedValue: x, relativeTo: textStyle) + _y = ScaledMetric(wrappedValue: y, relativeTo: textStyle) + } + + func body(content: Content) -> some View { + content.offset(x: x, y: y) + } +} + + +// MARK: - Previews + +struct ScaledOffsetModifier_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VStack { + ForEach(DynamicTypeSize.allCases, id: \.self) { size in + verifiedUserComposite + .dynamicTypeSize(size) + } + } + } + + static var verifiedUserComposite: some View { + CompoundIcon(\.userSolid) + .foregroundStyle(.compound.iconAccentTertiary) + .overlay { + CompoundIcon(\.verified, size: .custom(10), relativeTo: .body) + .scaledOffset(x: 6, y: 6) + .foregroundStyle(.compound.gradientActionStop1) + } + } +} diff --git a/compound-ios/Sources/Compound/Layout/ScaledPaddingModifier.swift b/compound-ios/Sources/Compound/Layout/ScaledPaddingModifier.swift new file mode 100644 index 000000000..ffd507706 --- /dev/null +++ b/compound-ios/Sources/Compound/Layout/ScaledPaddingModifier.swift @@ -0,0 +1,54 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +public extension View { + /// Adds an equal padding amount to all edges of this view, scaled relative to the user's selected font size. + func scaledPadding(_ length: CGFloat, relativeTo textStyle: Font.TextStyle = .body) -> some View { + scaledPadding(.all, length, relativeTo: textStyle) + } + + /// Adds an equal padding amount to specific edges of this view, scaled relative to the user's selected font size. + func scaledPadding(_ edges: Edge.Set, _ length: CGFloat, relativeTo textStyle: Font.TextStyle = .body) -> some View { + modifier(ScaledPaddingModifier(edges: edges, length: length, textStyle: textStyle)) + } +} + +private struct ScaledPaddingModifier: ViewModifier { + let edges: Edge.Set + @ScaledMetric var length: CGFloat + + init(edges: Edge.Set, length: CGFloat, textStyle: Font.TextStyle) { + self.edges = edges + _length = ScaledMetric(wrappedValue: length, relativeTo: textStyle) + } + + func body(content: Content) -> some View { + content.padding(edges, length) + } +} + +// MARK: - Previews + +struct ScaledPaddingModifier_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + VStack { + ForEach(DynamicTypeSize.allCases, id: \.self) { size in + userProfileButtonLabel + .dynamicTypeSize(size) + } + } + } + + static var userProfileButtonLabel: some View { + CompoundIcon(\.userProfile, size: .medium, relativeTo: .title) + .foregroundStyle(.compound.iconOnSolidPrimary) + .scaledPadding(6, relativeTo: .title) + .background(.compound.bgAccentRest, in: Circle()) + } +} diff --git a/compound-ios/Sources/Compound/List/ListInlinePicker.swift b/compound-ios/Sources/Compound/List/ListInlinePicker.swift new file mode 100644 index 000000000..71a9442b5 --- /dev/null +++ b/compound-ios/Sources/Compound/List/ListInlinePicker.swift @@ -0,0 +1,70 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +struct ListInlinePicker: View { + let title: String? + @Binding var selection: SelectedValue + let items: [(title: String, tag: SelectedValue)] + let isWaiting: Bool + + var body: some View { + ForEach(items, id: \.tag) { item in + ListRow(label: .plain(title: item.title), + details: isWaiting ? .isWaiting(selection == item.tag) : nil, + kind: .selection(isSelected: !isWaiting ? selection == item.tag : false) { + var transaction = Transaction() + transaction.disablesAnimations = true + + withTransaction(transaction) { + selection = item.tag + } + }) + } + } +} + +// MARK: - Previews + +struct ListInlinePicker_Previews: PreviewProvider, TestablePreview { + static var previews: some View { Preview() } + + struct Preview: View { + @State var selection = "Item 1" + + let items = ["Item 1", "Item 2", "Item 3"] + var body: some View { + Form { + Section("Compound") { + ListInlinePicker(title: "Title", + selection: $selection, + items: items.map { (title: $0, tag: $0) }, + isWaiting: false) + } + + Section("Compound with loader") { + ListInlinePicker(title: "Title", + selection: $selection, + items: items.map { (title: $0, tag: $0) }, + isWaiting: true) + } + + Section("Native") { + Picker("", selection: $selection) { + ForEach(items, id: \.self) { item in + Text(item) + .tag(item) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + } + } + } +} diff --git a/compound-ios/Sources/Compound/List/ListRow.swift b/compound-ios/Sources/Compound/List/ListRow.swift new file mode 100644 index 000000000..4a91460bf --- /dev/null +++ b/compound-ios/Sources/Compound/List/ListRow.swift @@ -0,0 +1,476 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +public enum ListRowPadding { + public static let horizontal: CGFloat = 16 + public static let vertical: CGFloat = 13 + public static let insets = EdgeInsets(top: vertical, + leading: horizontal, + bottom: vertical, + trailing: horizontal) + + public static let textFieldInsets = EdgeInsets(top: 11, + leading: horizontal, + bottom: 11, + trailing: horizontal) +} + +public struct ListRow: View { + @Environment(\.isEnabled) private var isEnabled + + let label: ListRowLabel + let details: ListRowDetails? + + public enum Kind { + case label + case button(action: () -> Void) + case navigationLink(action: () -> Void) + case picker(selection: Binding, items: [(title: String, tag: SelectionValue)]) + case toggle(Binding) + case inlinePicker(selection: Binding, items: [(title: String, tag: SelectionValue)]) + case selection(isSelected: Bool, action: () -> Void) + case multiSelection(isSelected: Bool, action: () -> Void) + case textField(text: Binding, axis: Axis?) + case secureField(text: Binding) + + case custom(() -> CustomContent) + + public static func textField(text: Binding) -> Self { + .textField(text: text, axis: nil) + } + } + + let kind: Kind + + public var body: some View { + rowContent + .buttonStyle(ListRowButtonStyle()) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.compound.bgCanvasDefaultLevel1) + .listRowSeparatorTint(.compound._borderInteractiveSecondaryAlpha) + } + + @ViewBuilder + var rowContent: some View { + switch kind { + case .label: + RowContent(details: details) { label } + case .button(let action): + Button(action: action) { + RowContent(details: details) { label } + } + case .navigationLink(let action): + Button(action: action) { + RowContent(details: details, accessory: .navigationLink) { label } + } + case .picker(let selection, let items): + HStack(spacing: 0) { + label + Spacer() + // Note: VoiceOver label already provided. + Picker("", selection: selection) { + ForEach(items, id: \.tag) { item in + Text(item.title) + .tag(item.tag) + } + } + .labelsHidden() + .padding(.vertical, -10) + .padding(.trailing, ListRowPadding.horizontal) + } + .accessibilityElement(children: .combine) + case .toggle(let binding): + HStack(spacing: 0) { + label + Spacer() + HStack(spacing: ListRowTrailingSectionSpacing.horizontal) { + if let details { + ListRowTrailingSection(details) + } + + // Note: VoiceOver label already provided. + Toggle("", isOn: binding) + .toggleStyle(.compound) + .labelsHidden() + .padding(.vertical, -10) + } + } + .padding(.trailing, ListRowPadding.horizontal) + .accessibilityElement(children: .combine) + case .inlinePicker(let selection, let items): + ListInlinePicker(title: label.title ?? "", + selection: selection, + items: items, + isWaiting: details?.isWaiting ?? false) + case .selection(let isSelected, let action): + Button(action: action) { + RowContent(details: details, accessory: .selection(isSelected)) { label } + } + .isToggle() + case .multiSelection(let isSelected, let action): + Button(action: action) { + RowContent(details: details, accessory: .multiSelection(isSelected)) { label } + } + .isToggle() + case .textField(let text, let axis): + TextField(text: text, axis: axis) { + Text(label.title ?? "") + .compoundTextFieldPlaceholder() + } + .tint(.compound.iconAccentTertiary) + .foregroundStyle(isEnabled ? .compound.textPrimary : .compound.textDisabled) + .listRowInsets(ListRowPadding.textFieldInsets) + case .secureField(let text): + SecureField(text: text) { + Text(label.title ?? "") + .compoundTextFieldPlaceholder() + } + .tint(.compound.iconAccentTertiary) + .foregroundStyle(isEnabled ? .compound.textPrimary : .compound.textDisabled) + .listRowInsets(ListRowPadding.textFieldInsets) + + case .custom(let content): + content() + } + } +} + +// MARK: - Initialisers + +// Normal row with a details icon +public extension ListRow where CustomContent == EmptyView { + init(label: ListRowLabel, + details: ListRowDetails? = nil, + kind: Kind) { + self.label = label + self.details = details + self.kind = kind + } + + init(label: ListRowLabel, + details: ListRowDetails? = nil, + kind: Kind) where SelectionValue == String { + self.label = label + self.details = details + self.kind = kind + } +} + +// Normal row without a details icon. +public extension ListRow where DetailsIcon == EmptyView, CustomContent == EmptyView { + init(label: ListRowLabel, + details: ListRowDetails? = nil, + kind: Kind) { + self.label = label + self.details = details + self.kind = kind + } + + init(label: ListRowLabel, + details: ListRowDetails? = nil, + kind: Kind) where SelectionValue == String { + self.label = label + self.details = details + self.kind = kind + } +} + +// Custom row without a label or details label. +public extension ListRow where Icon == EmptyView, DetailsIcon == EmptyView { + init(kind: Kind) { + self.label = ListRowLabel() + self.details = nil + self.kind = kind + } + + init(kind: Kind) where SelectionValue == String { + self.label = ListRowLabel() + self.details = nil + self.kind = kind + } +} + +/// The standard content for labels, and button based rows. +/// +/// This doesn't use `LabeledContent` as that will happily build using an `EmptyView` +/// in the content. This creates an issue where the label ends up hidden to VoiceOver, +/// presumably because SwiftUI thinks that the row doesn't contain any content. +private struct RowContent: View { + let details: ListRowDetails? + var accessory: ListRowAccessory? + let label: () -> Label + + var body: some View { + HStack(spacing: ListRowTrailingSectionSpacing.horizontal) { + label() + .frame(maxWidth: .infinity) + + if details != nil || accessory != nil { + ListRowTrailingSection(details, accessory: accessory) + } + } + .frame(maxHeight: .infinity) + .padding(.trailing, ListRowPadding.horizontal) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Helpers + +private extension TextField { + /// Creates a text field with an optional preferred axis. Hard coding a default resulted + /// in the underlying component always being a `UITextView` during introspection. + /// This initialiser does the right thing when not supplying an axis in `ListRow.Kind`. + init(text: Binding, axis: Axis?, label: () -> Label) { + if let axis { + self.init(text: text, axis: axis, label: label) + } else { + self.init(text: text, label: label) + } + } +} + +private extension Button { + /// Adds the `isToggle` accessibility trait on iOS 17+ + @ViewBuilder func isToggle() -> some View { + if #available(iOS 17.0, *) { + accessibilityAddTraits(.isToggle) + } else { + self + } + } +} + +// MARK: - Previews + +public struct ListRow_Previews: PreviewProvider, TestablePreview { + public static var previews: some View { + Form { + Section { + labels + buttons + pickers + toggles + selection + actionButtons + plainButton + } + + centeredActionButtonSections + descriptionLabelSection + avatarSection + othersSection + } + .compoundList() + .frame(idealHeight: 2100) // Snapshot height + .previewLayout(.sizeThatFits) + } + + static var labels: some View { + ListRow(label: .default(title: "Label", + description: "Non-interactive item", + systemIcon: .squareDashed), + details: .label(title: "Content", + systemIcon: .squareDashed, + isWaiting: true), + kind: .label) + } + + @ViewBuilder static var buttons: some View { + ListRow(label: .default(title: "Title", + description: "Description…", + systemIcon: .squareDashed), + kind: .button { print("I was tapped!") }) + ListRow(label: .default(title: "Title", + systemIcon: .squareDashed), + kind: .button { print("I was tapped!") }) + ListRow(label: .default(title: "Destructive", + systemIcon: .squareDashed, + role: .destructive), + kind: .button { print("I will delete things!") }) + ListRow(label: .default(title: "Title", + description: "Description…", + systemIcon: .squareDashed), + details: .label(title: "Details", systemIcon: .squareDashed), + kind: .navigationLink { print("Perform navigation!") }) + ListRow(label: .default(title: "Title", + systemIcon: .squareDashed), + kind: .navigationLink { print("Perform navigation!") }) + } + + @ViewBuilder static var pickers: some View { + ListRow(label: .default(title: "Title", + description: "Description…", + systemIcon: .squareDashed), + kind: .picker(selection: .constant(0), + items: [(title: "Item 1", tag: 0), + (title: "Item 2", tag: 1), + (title: "Item 3", tag: 2)])) + ListRow(label: .default(title: "Very very very very very very long title", + description: "Description…", + systemIcon: .squareDashed), + kind: .picker(selection: .constant(0), + items: [(title: "Item 1", tag: 0), + (title: "Item 2", tag: 1), + (title: "Item 3", tag: 2)])) + ListRow(label: .default(title: "Title", systemIcon: .squareDashed), + kind: .picker(selection: .constant("Item 1"), + items: [(title: "Item 1", tag: "Item 1"), + (title: "Item 2", tag: "Item 2"), + (title: "Item 3", tag: "Item 3")])) + } + + @ViewBuilder static var toggles: some View { + ListRow(label: .default(title: "Title", + description: "Description…", + systemIcon: .squareDashed), + kind: .toggle(.constant(true))) + ListRow(label: .default(title: "Very very very very very very very long title", + description: "Description…", + systemIcon: .squareDashed), + kind: .toggle(.constant(true))) + ListRow(label: .default(title: "Title", systemIcon: .squareDashed), + kind: .toggle(.constant(true))) + ListRow(label: .default(title: "Title", systemIcon: .squareDashed), + details: .isWaiting(true), + kind: .toggle(.constant(false))) + } + + @ViewBuilder static var selection: some View { + ListRow(label: .default(title: "Title", + description: "Description…", + systemIcon: .squareDashed), + details: .title("Content"), + kind: .selection(isSelected: true) { + print("I was tapped!") + }) + ListRow(label: .default(title: "Title", + systemIcon: .squareDashed), + details: .title("Content"), + kind: .selection(isSelected: true) { + print("I was tapped!") + }) + + ListRow(label: .plain(title: "Title"), + kind: .inlinePicker(selection: .constant("Item 1"), + items: [(title: "Item 1", tag: "Item 1"), + (title: "Item 2", tag: "Item 2"), + (title: "Item 3", tag: "Item 3")])) + } + + @ViewBuilder static var actionButtons: some View { + ListRow(label: .action(title: "Title", + systemIcon: .squareDashed), + kind: .button { print("I was tapped!") }) + ListRow(label: .action(title: "Title", + systemIcon: .squareDashed, + role: .destructive), + kind: .button { print("I was tapped!") }) + ListRow(label: .action(title: "Title", + systemIcon: .squareDashed), + kind: .button { print("I was tapped!") }) + .disabled(true) + } + + static var plainButton: some View { + ListRow(label: .plain(title: "Title"), + kind: .button { print("I was tapped!") }) + } + + @ViewBuilder static var centeredActionButtonSections: some View { + Section { + ListRow(label: .centeredAction(title: "Title", + systemIcon: .squareDashed), + kind: .button { print("I was tapped!") }) + } + + Section { + ListRow(label: .centeredAction(title: "Title", + systemIcon: .squareDashed, + role: .destructive), + kind: .button { print("I was tapped!") }) + } + + Section { + ListRow(label: .centeredAction(title: "Title", + systemIcon: .squareDashed), + kind: .button { print("I was tapped!") }) + .disabled(true) + } + } + + static var descriptionLabelSection: some View { + Section { + ListRow(label: .description("This is a row in the list, with a multiline description but it doesn't have either an icon or a title, just this text here."), + kind: .label) + } + } + + static var avatarSection: some View { + Section { + ListRow(label: .avatar(title: "Alice", + description: "@alice:element.io", + icon: Circle().foregroundStyle(.compound.decorativeColors[0].background)), + kind: .multiSelection(isSelected: true) { }) + ListRow(label: .avatar(title: "Bob", + description: "@bob:element.io", + icon: Circle().foregroundStyle(.compound.decorativeColors[1].background)), + kind: .multiSelection(isSelected: false) { }) + ListRow(label: .avatar(title: "Dan", + status: "Pending", + description: "@dan:element.io", + icon: Circle().foregroundStyle(.compound.decorativeColors[3].background)), + kind: .multiSelection(isSelected: false) { }) + .disabled(true) + ListRow(label: .avatar(title: "@charlie:fake.com", + description: "This user can't be found, so the invite may not be received.", + icon: Circle().foregroundStyle(.compound.decorativeColors[2].background), + role: .error), + kind: .button { }) + } + } + + @ViewBuilder static var othersSection: some View { + Section { + ListRow(kind: .custom { + Text("This is a custom row") + .padding(.horizontal, 16) + .padding(.vertical, 20) + }) + ListRow(label: .plain(title: "Placeholder"), + kind: .textField(text: .constant("This is a disabled text field"))) + .disabled(true) + ListRow(label: .plain(title: "Placeholder"), + kind: .textField(text: .constant(""), axis: .vertical)) + .lineLimit(4...) + ListRow(label: .plain(title: "Password"), + kind: .secureField(text: .constant("p4ssw0rd"))) + } + } +} + +struct ListRowLoadingSelection_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + Form { + ListRow(label: .plain(title: "Selected", + description: "This is a long multiline description which shows what happens when wrapping with a details view and selection, specifically, an activity indicator in the details."), + details: .isWaiting(false), + kind: .selection(isSelected: true) { }) + ListRow(label: .plain(title: "Unselected", + description: "This is a long multiline description which shows what happens when wrapping with a details view and selection, specifically, an activity indicator in the details."), + details: .isWaiting(false), + kind: .selection(isSelected: false) { }) + ListRow(label: .plain(title: "Unselected & Loading", + description: "This is a long multiline description which shows what happens when wrapping with a details view and selection, specifically, an activity indicator in the details."), + details: .isWaiting(true), + kind: .selection(isSelected: false) { }) + } + .compoundList() + } +} diff --git a/compound-ios/Sources/Compound/List/ListRowAccessory.swift b/compound-ios/Sources/Compound/List/ListRowAccessory.swift new file mode 100644 index 000000000..f94140a9d --- /dev/null +++ b/compound-ios/Sources/Compound/List/ListRowAccessory.swift @@ -0,0 +1,112 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +/// A view to be added on the trailing edge of a form row. +public struct ListRowAccessory: View { + @Environment(\.isEnabled) private var isEnabled + + enum Kind { + /// A navigation chevron. + case navigationLink + /// A checkmark. + case selected + /// ``selected`` but invisible to reserve space. + case unselected + /// A circular checkmark. + case multiSelected + /// An empty circle. + case multiUnselected + } + + /// A chevron to indicate that the button pushes another screen. + public static var navigationLink: Self { + Self.init(kind: .navigationLink) + } + + /// A checkmark (or reserved space) to indicate that the row is selected. + public static func selection(_ isSelected: Bool) -> Self { + Self.init(kind: isSelected ? .selected : .unselected) + } + + /// A circular checkmark (or empty circle) to indicate that the row is one of multiple selected. + public static func multiSelection(_ isSelected: Bool) -> Self { + Self.init(kind: isSelected ? .multiSelected : .multiUnselected) + } + + let kind: Kind + + /// Negative padding added to prevent the accessory interfering with the row's padding. + private var verticalPaddingFix: CGFloat { -4 } + /// Absolute bodge until we have the circle icon in Compound. + @ScaledMetric private var circleOverlayInsets = 5 + + public var body: some View { + switch kind { + case .navigationLink: + CompoundIcon(\.chevronRight) + .foregroundColor(.compound.iconTertiaryAlpha) + .flipsForRightToLeftLayoutDirection(true) + case .selected: + CompoundIcon(\.check) + .foregroundColor(isEnabled ? .compound.iconAccentPrimary : .compound.iconDisabled) + .accessibilityAddTraits(.isSelected) + .padding(.vertical, verticalPaddingFix) + case .unselected: + CompoundIcon(\.check) + .hidden() + .padding(.vertical, verticalPaddingFix) + case .multiSelected: + CompoundIcon(\.checkCircleSolid) + .foregroundColor(isEnabled ? .compound.iconSuccessPrimary : .compound.iconDisabled) + .accessibilityAddTraits(.isSelected) + .padding(.vertical, verticalPaddingFix) + case .multiUnselected: + CompoundIcon(\.circle) + .foregroundColor(isEnabled ? .compound.borderInteractivePrimary : .compound.borderDisabled) + .padding(.vertical, verticalPaddingFix) + } + } +} + +// MARK: - Previews + +struct ListRowAccessory_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + Grid(alignment: .leading, verticalSpacing: 16) { + row(title: "Navigation Link", accessory: .navigationLink) + row(title: "Navigation Link disabled", accessory: .navigationLink) + .disabled(true) + + row(title: "Selected", accessory: .selection(true)) + row(title: "Selected disabled", accessory: .selection(true)) + .disabled(true) + + row(title: "Unselected", accessory: .selection(false)) + row(title: "Unselected disabled", accessory: .selection(false)) + .disabled(true) + + row(title: "Multi-selected", accessory: .multiSelection(true)) + row(title: "Multi-selected disabled", accessory: .multiSelection(true)) + .disabled(true) + + row(title: "Multi-unselected", accessory: .multiSelection(false)) + row(title: "Multi-unselected disabled", accessory: .multiSelection(false)) + .disabled(true) + } + .previewDisplayName("Accessories") + } + + static func row(title: String, accessory: ListRowAccessory) -> some View { + GridRow { + accessory + Text(title) + .foregroundStyle(.compound.textSecondary) + } + } +} diff --git a/compound-ios/Sources/Compound/List/ListRowButtonStyle.swift b/compound-ios/Sources/Compound/List/ListRowButtonStyle.swift new file mode 100644 index 000000000..b7dd16154 --- /dev/null +++ b/compound-ios/Sources/Compound/List/ListRowButtonStyle.swift @@ -0,0 +1,65 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +// TODO: Check if the primitive style is actually needed now the insets are part of ListRow. +// It might still be useful for ListRow(kind: .custom) usage? + +/// Default button styling for list rows. +/// +/// The primitive style is needed to set the list row insets to `0`. The inner style is then needed +/// to change the background colour depending on whether the button is currently pressed or not. +public struct ListRowButtonStyle: PrimitiveButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + Button(role: configuration.role, action: configuration.trigger) { + configuration.label + } + .buttonStyle(Style()) + } + + /// Inner style used to set the pressed background colour. + struct Style: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .contentShape(Rectangle()) + .background(configuration.isPressed ? Color.compound.bgSubtlePrimary : .compound.bgCanvasDefaultLevel1) + } + } +} + +// MARK: - Previews + +// TODO: Fix the previews, either the style should expand the label to fill or +// the previews need to do this manually for demonstration purposes. + +public struct ListRowButtonStyle_Previews: PreviewProvider, TestablePreview { + public static var previews: some View { + Form { + Section { + Button("Title") { } + .buttonStyle(ListRowButtonStyle.Style()) + } + .listRowInsets(EdgeInsets()) + + Section { + Button("Title") { } + Button("Title") { } + Button("Title") { } + } + .buttonStyle(ListRowButtonStyle()) + .listRowInsets(EdgeInsets()) + + Section { + ShareLink(item: "test") + .buttonStyle(ListRowButtonStyle()) + } + .listRowInsets(EdgeInsets()) + } + .compoundList() + } +} diff --git a/compound-ios/Sources/Compound/List/ListRowDetails.swift b/compound-ios/Sources/Compound/List/ListRowDetails.swift new file mode 100644 index 000000000..b610298b4 --- /dev/null +++ b/compound-ios/Sources/Compound/List/ListRowDetails.swift @@ -0,0 +1,94 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import CompoundDesignTokens +import SFSafeSymbols +import SwiftUI + +/// The configuration of the details portion of a list row's trailing section. +/// This consists of the title, icon and a waiting indicator. +public struct ListRowDetails { + var title: String? + var icon: Icon? + var counter: Int? + + var isWaiting = false + + // MARK: - Initialisers + + public static func label(title: String, + icon: Icon, + counter: Int? = nil, + isWaiting: Bool = false) -> Self { + ListRowDetails(title: title, + icon: icon, + counter: counter, + isWaiting: isWaiting) + } + + public static func label(title: String, + icon: KeyPath, + counter: Int? = nil, + isWaiting: Bool = false) -> Self where Icon == CompoundIcon { + ListRowDetails(title: title, + icon: CompoundIcon(icon), + counter: counter, + isWaiting: isWaiting) + } + + public static func label(title: String, + systemIcon: SFSymbol, + counter: Int? = nil, + isWaiting: Bool = false) -> Self where Icon == Image { + ListRowDetails(title: title, + icon: Image(systemSymbol: systemIcon), + counter: counter, + isWaiting: isWaiting) + } + + public static func icon(_ icon: Icon, + counter: Int? = nil, + isWaiting: Bool = false) -> Self { + ListRowDetails(icon: icon, + counter: counter, + isWaiting: isWaiting) + } + + public static func icon(_ icon: KeyPath, + counter: Int? = nil, + isWaiting: Bool = false) -> Self where Icon == CompoundIcon { + ListRowDetails(icon:CompoundIcon(icon), + counter: counter, + isWaiting: isWaiting) + } + + public static func systemIcon(_ systemIcon: SFSymbol, + counter: Int? = nil, + isWaiting: Bool = false) -> Self where Icon == Image { + ListRowDetails(icon: Image(systemSymbol: systemIcon), + counter: counter, + isWaiting: isWaiting) + } +} + +public extension ListRowDetails where Icon == Image { + static func title(_ title: String, + counter: Int? = nil, + isWaiting: Bool = false) -> Self { + ListRowDetails(title: title, + counter: counter, + isWaiting: isWaiting) + } + + static func counter(_ counter: Int, isWaiting: Bool = false) -> Self { + ListRowDetails(counter: counter, isWaiting: isWaiting) + } + + static func isWaiting(_ isWaiting: Bool) -> Self { + ListRowDetails(isWaiting: isWaiting) + } +} diff --git a/compound-ios/Sources/Compound/List/ListRowLabel.swift b/compound-ios/Sources/Compound/List/ListRowLabel.swift new file mode 100644 index 000000000..c13f457b5 --- /dev/null +++ b/compound-ios/Sources/Compound/List/ListRowLabel.swift @@ -0,0 +1,373 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import CompoundDesignTokens +import SFSafeSymbols +import SwiftUI + +/// The main label style used in the leading section of `ListRow`. +struct ListRowLabelStyle: LabelStyle { + let iconAlignment: VerticalAlignment + + func makeBody(configuration: Configuration) -> some View { + HStack(alignment: iconAlignment, spacing: 16) { + configuration.icon + configuration.title + } + } +} + +/// The label style used in `ListRow` for centred buttons. +struct ListRowCenteredLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(alignment: .center, spacing: 8) { + configuration.icon + configuration.title + } + } +} + +/// The label style used in the leading section of `ListRow` that show an avatar as the icon. +/// +/// Unlike the other styles, this one sizes the avatar internally. +struct ListRowAvatarLabelStyle: LabelStyle { + @ScaledMetric private var avatarSize = 32.0 + + func makeBody(configuration: Configuration) -> some View { + HStack(alignment: .center, spacing: 16) { + configuration.icon + .frame(width: avatarSize, height: avatarSize) + .padding(.vertical, -5) // Don't allow the avatar to size the row. + configuration.title + } + } +} + +public struct ListRowLabel: View { + @Environment(\.isEnabled) private var isEnabled + @Environment(\.lineLimit) private var lineLimit + @ScaledMetric private var iconSize = 30.0 + + var title: String? + var status: String? + var description: String? + var icon: Icon? + + var role: Role? + public enum Role { + /// A role that indicates a destructive action. + case destructive + /// A role that indicates an error. + /// + /// The label should contain a description when using this role. + case error + } + + var iconAlignment: VerticalAlignment = .center + var hideIconBackground: Bool = false + + enum Layout { case `default`, centered, avatar } + var layout: Layout = .default + + var titleColor: Color { + guard isEnabled else { return .compound.textDisabled } + return role == .destructive ? .compound.textCriticalPrimary : .compound.textPrimary + } + var titleLineLimit: Int? { layout == .avatar ? 1 : lineLimit } + + var statusColor: Color { + isEnabled ? .compound.textSecondary : .compound.textDisabled + } + + var descriptionColor: Color { + isEnabled ? .compound.textSecondary : .compound.textDisabled + } + var descriptionLineLimit: Int? { + guard layout == .avatar else { return lineLimit } + return role != .error ? 1 : lineLimit + } + + var iconForegroundColor: Color { + guard isEnabled else { return .compound.iconTertiaryAlpha } + if role == .destructive { return .compound.textCriticalPrimary } + return hideIconBackground ? .compound.iconPrimary : .compound.iconTertiaryAlpha + } + + var iconBackgroundColor: Color { + if hideIconBackground { return .clear } + guard isEnabled else { return .compound._bgSubtleSecondaryAlpha } + return role == .destructive ? .compound._bgCriticalSubtleAlpha : .compound._bgSubtleSecondaryAlpha + } + + public var body: some View { + Group { + switch layout { + case .default: + defaultBody + case .centered: + centeredBody + case .avatar: + avatarBody + } + } + .padding(.leading, ListRowPadding.horizontal) + .padding(.vertical, ListRowPadding.vertical) + } + + var defaultBody: some View { + Label { + titleAndDescription + } icon: { + icon + .foregroundColor(iconForegroundColor) + .frame(width: iconSize, height: iconSize) + .background(iconBackgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.vertical, -4) // Don't allow the background to size the row. + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .labelStyle(ListRowLabelStyle(iconAlignment: iconAlignment)) + } + + var centeredBody: some View { + Label { + if let title { + Text(title) + .font(.compound.bodyLG) + .foregroundColor(titleColor) + } + } icon: { + icon + .foregroundColor(iconForegroundColor) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .labelStyle(ListRowCenteredLabelStyle()) + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + } + + var avatarBody: some View { + Label { + titleAndDescription + } icon: { + icon // Layout handled by the style. + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .labelStyle(ListRowAvatarLabelStyle()) + } + + var titleAndDescription: some View { + VStack(alignment: .leading, spacing: 2) { + if let title { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title) + .font(.compound.bodyLG) + .foregroundColor(titleColor) + .lineLimit(titleLineLimit) + + // Status is only available in the avatar init which requires a title, + // so no need to worry about the outer `if let` being nil in this instance. + if let status { + Text(status) + .font(.compound.bodySM) + .foregroundColor(statusColor) + .lineLimit(1) + } + } + } + + if let description { + HStack(alignment: .top, spacing: 4) { + if role == .error { + CompoundIcon(\.errorSolid, size: .xSmall, relativeTo: .compound.bodySM) + .foregroundStyle(.compound.iconCriticalPrimary) + } + + Text(description) + .font(.compound.bodySM) + .foregroundColor(descriptionColor) + .lineLimit(descriptionLineLimit) + } + } + } + .accessibilityElement(children: .combine) + } + + // MARK: - Initialisers + + public static func `default`(title: String, + description: String? = nil, + icon: Icon, + role: ListRowLabel.Role? = nil, + iconAlignment: VerticalAlignment = .center) -> ListRowLabel { + ListRowLabel(title: title, + description: description, + icon: icon, + role: role, + iconAlignment: iconAlignment) + } + + public static func `default`(title: String, + description: String? = nil, + icon: KeyPath, + role: ListRowLabel.Role? = nil, + iconAlignment: VerticalAlignment = .center) -> ListRowLabel where Icon == CompoundIcon { + .default(title: title, + description: description, + icon: CompoundIcon(icon), + role: role, + iconAlignment: iconAlignment) + } + + public static func `default`(title: String, + description: String? = nil, + systemIcon: SFSymbol, + role: ListRowLabel.Role? = nil, + iconAlignment: VerticalAlignment = .center) -> ListRowLabel where Icon == Image { + .default(title: title, + description: description, + icon: Image(systemSymbol: systemIcon), + role: role, + iconAlignment: iconAlignment) + } + + public static func action(title: String, + icon: Icon, + role: ListRowLabel.Role? = nil) -> ListRowLabel { + ListRowLabel(title: title, + icon: icon, + role: role, + hideIconBackground: true) + } + + public static func action(title: String, + icon: KeyPath, + role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == CompoundIcon { + .action(title: title, icon: CompoundIcon(icon), role: role) + } + + public static func action(title: String, + systemIcon: SFSymbol, + role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == Image { + .action(title: title, icon: Image(systemSymbol: systemIcon), role: role) + } + + public static func centeredAction(title: String, + icon: Icon, + role: ListRowLabel.Role? = nil) -> ListRowLabel { + ListRowLabel(title: title, + icon: icon, + role: role, + hideIconBackground: true, + layout: .centered) + } + + public static func centeredAction(title: String, + icon: KeyPath, + role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == CompoundIcon { + .centeredAction(title: title, icon: CompoundIcon(icon), role: role) + } + + public static func centeredAction(title: String, + systemIcon: SFSymbol, + role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == Image { + .centeredAction(title: title, icon: Image(systemSymbol: systemIcon), role: role) + } + + public static func plain(title: String, + description: String? = nil, + role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == EmptyView { + ListRowLabel(title: title, description: description, role: role, hideIconBackground: true) + } + + public static func description(_ description: String) -> ListRowLabel where Icon == EmptyView { + ListRowLabel(description: description) + } + + /// A label that displays an avatar as it's icon, such as a user profile row or for a room picker. + public static func avatar(title: String, + status: String? = nil, + description: String? = nil, + icon: Icon, + role: ListRowLabel.Role? = nil) -> ListRowLabel { + ListRowLabel(title: title, + status: status, + description: description, + icon: icon, + role: role, + layout: .avatar) + } +} + +// MARK: - Previews + +struct ListRowLabel_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + Form { + Section { + Group { + ListRowLabel.default(title: "Person", icon: Image(systemName: "person")) + + ListRowLabel.default(title: "Help", + description: "Supporting text", + systemIcon: .questionmarkCircle) + + ListRowLabel.default(title: "Trash", + icon: Image(systemName: "trash"), + role: .destructive) + } + + Group { + ListRowLabel.action(title: "Camera", + icon: Image(systemName: "camera")) + + ListRowLabel.action(title: "Remove", + icon: Image(systemName: "person.badge.minus"), + role: .destructive) + + ListRowLabel.centeredAction(title: "Person", + icon: Image(systemName: "person")) + ListRowLabel.centeredAction(title: "Remove", + systemIcon: .personBadgeMinus, + role: .destructive) + } + + Group { + ListRowLabel.plain(title: "Person") + ListRowLabel.plain(title: "Remove", + role: .destructive) + ListRowLabel.plain(title: "Plain", description: "Description") + } + + ListRowLabel.description("This is a row in the list, that only contains a description and doesn't have either an icon or a title.") + } + .listRowInsets(EdgeInsets()) + + Section { + ListRowLabel.avatar(title: "Alice", + description: "@alice:example.com", + icon: Circle().foregroundStyle(.compound.decorativeColors[0].background)) + ListRowLabel.avatar(title: "Alice", + status: "Pending", + description: "@alice:example.com", + icon: Circle().foregroundStyle(.compound.decorativeColors[0].background)) + ListRowLabel.avatar(title: "@bob:idontexist.com", + description: "This user can't be found, so the invite may not be received.", + icon: Circle().foregroundStyle(.compound.decorativeColors[0].background), + role: .error) + } + .listRowInsets(EdgeInsets()) + + Section { + ListRow(label: .description("This is a row in the list, with a multiline description but it doesn't have either an icon or a title, just this text here."), + kind: .label) + } + } + .compoundList() + .frame(idealHeight: 1000) // Snapshot height + .previewLayout(.sizeThatFits) + } +} diff --git a/compound-ios/Sources/Compound/List/ListRowTrailingSection.swift b/compound-ios/Sources/Compound/List/ListRowTrailingSection.swift new file mode 100644 index 000000000..cc5995664 --- /dev/null +++ b/compound-ios/Sources/Compound/List/ListRowTrailingSection.swift @@ -0,0 +1,133 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +/// The spacing used inside of a ListRow +enum ListRowTrailingSectionSpacing { + static let horizontal = 8.0 +} + +/// The style applied to the details label in a list row's trailing section. +private struct ListRowDetailsLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: ListRowTrailingSectionSpacing.horizontal) { + configuration.title + .foregroundColor(.compound.textSecondary) + configuration.icon + .foregroundColor(.compound.iconPrimary) + } + .font(.compound.bodyLG) + } +} + +/// The view shown to the right of the `ListRowLabel` inside of a `ListRow`. +/// This consists of both the `ListRowDetails` and the `ListRowAccessory`. +public struct ListRowTrailingSection: View { + @Environment(\.isEnabled) private var isEnabled + + private var title: String? + private var icon: Icon? + private var counter: Int? + private var isWaiting = false + private var accessory: ListRowAccessory? + + @ScaledMetric private var iconSize = 24 + private var hideAccessory: Bool { isWaiting && accessory?.kind == .unselected } + + init(_ details: ListRowDetails?, accessory: ListRowAccessory? = nil) { + title = details?.title + icon = details?.icon + isWaiting = details?.isWaiting ?? false + counter = details?.counter + self.accessory = accessory + } + + public var body: some View { + HStack(spacing: ListRowTrailingSectionSpacing.horizontal) { + if isWaiting { + ProgressView() + } + + if title != nil || icon != nil { + Label { + title.map(Text.init) + } icon: { + icon + } + .labelStyle(ListRowDetailsLabelStyle()) + } + + if let counter { + Text("\(counter)") + .font(.compound.bodyLG) + .foregroundStyle(.compound.textOnSolidPrimary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background { Capsule().fill(isEnabled ? .compound.iconSuccessPrimary : .compound.iconDisabled) } + } + + if let accessory, !hideAccessory { + accessory + } + } + .frame(minWidth: iconSize) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Previews + +struct ListRowTrailingSection_Previews: PreviewProvider, TestablePreview { + static let someCondition = true + static let otherCondition = true + + static var previews: some View { + VStack(spacing: 40) { + details + withAccessory + } + } + + static var details: some View { + VStack(spacing: 20) { + ListRowTrailingSection(.label(title: "Content", icon: Image(systemName: "square.dashed"))) + ListRowTrailingSection(.label(title: "Content", systemIcon: .squareDashed)) + ListRowTrailingSection(.title("Content")) + ListRowTrailingSection(.icon(Image(systemName: "square.dashed"))) + ListRowTrailingSection(.systemIcon(.squareDashed)) + ListRowTrailingSection(.isWaiting(true)) + + ListRowTrailingSection(.systemIcon(.checkmark)) + ListRowTrailingSection(.title("Hello")) + + ListRowTrailingSection(someCondition ? .isWaiting(true) : otherCondition ? .systemIcon(.checkmark) : .title("Hello")) + + ListRowTrailingSection(.title("Hello", counter: 1)) + ListRowTrailingSection(.title("Hello", counter: 1)) + .disabled(true) + } + } + + static var withAccessory: some View { + VStack(spacing: 20) { + ListRowTrailingSection(.isWaiting(true), accessory: .selection(true)) + .border(.purple) + + // The checkmark should be hidden. + ListRowTrailingSection(.isWaiting(true), accessory: .selection(false)) + .border(.purple) + + // The checkmark's space should be reserved. + ListRowTrailingSection(.isWaiting(false), accessory: .selection(false)) + .border(.purple) + + ListRowTrailingSection(.counter(1), accessory: .navigationLink) + .border(.purple) + } + } +} diff --git a/compound-ios/Sources/Compound/List/ListStyles.swift b/compound-ios/Sources/Compound/List/ListStyles.swift new file mode 100644 index 000000000..adc986bbd --- /dev/null +++ b/compound-ios/Sources/Compound/List/ListStyles.swift @@ -0,0 +1,88 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +public extension View { + /// Styles a list using the Compound design tokens. + func compoundList() -> some View { + environment(\.defaultMinListRowHeight, 48) + .scrollContentBackground(.hidden) + .background(Color.compound.bgSubtleSecondaryLevel0.ignoresSafeArea()) + } + + /// Styles a list section header using the Compound design tokens. + func compoundListSectionHeader() -> some View { + font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + .listRowInsets(EdgeInsets(top: 15, + leading: ListRowPadding.horizontal, + bottom: 8, + trailing: ListRowPadding.horizontal)) + } + + /// Styles a list section footer using the Compound design tokens. + func compoundListSectionFooter() -> some View { + font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + .listRowInsets(EdgeInsets(top: 8, + leading: ListRowPadding.horizontal, + bottom: 10, + trailing: ListRowPadding.horizontal)) + } +} + +struct ListTextStyles_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + Form { + Section { + ListRow(label: .plain(title: "Hi!"), kind: .label) + } footer: { + Text("This is a footer down ere") + .compoundListSectionFooter() + } + + Section { + ListRow(label: .plain(title: "Second!"), kind: .label) + } header: { + Text("Section Title") + .compoundListSectionHeader() + } + + Section { + ListRow(label: .plain(title: "Third!"), kind: .label) + } header: { + Text("Section Title") + .compoundListSectionHeader() + } + + Section { + ListRow(label: .plain(title: "I was slow, I'm last."), kind: .label) + } footer: { + Text("This is a footer down ere") + .compoundListSectionFooter() + } + } + .compoundList() + .previewDisplayName("Form") + + List { + Section { + ListRow(label: .plain(title: "Hello"), kind: .label) + ListRow(label: .plain(title: "World!"), kind: .label) + } header: { + Text("Section Title") + .compoundListSectionHeader() + } footer: { + Text("Section footer") + .compoundListSectionFooter() + } + } + .compoundList() + .previewDisplayName("List") + } +} diff --git a/compound-ios/Sources/Compound/Previews/Snapshotting.swift b/compound-ios/Sources/Compound/Previews/Snapshotting.swift new file mode 100644 index 000000000..8142ebe8a --- /dev/null +++ b/compound-ios/Sources/Compound/Previews/Snapshotting.swift @@ -0,0 +1,42 @@ +// +// Copyright 2025 New Vector Ltd +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Combine +import SwiftUI + +struct SnapshotPrecisionPreferenceKey: PreferenceKey { + static var defaultValue: Float = 1.0 + + static func reduce(value: inout Float, nextValue: () -> Float) { + value = nextValue() + } +} + +struct SnapshotPerceptualPrecisionPreferenceKey: PreferenceKey { + static var defaultValue: Float = 0.98 + + static func reduce(value: inout Float, nextValue: () -> Float) { + value = nextValue() + } +} + +extension SwiftUI.View { + /// Use this modifier when you want to apply snapshot-specific preferences, + /// like delay and precision, to the view. + /// These preferences can then be retrieved and used elsewhere in your view hierarchy. + /// + /// - Parameters: + /// - delay: The delay time in seconds that you want to set as a preference to the View. + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. 98-99% mimics the precision of the human eye. + func snapshotPreferences(expect fulfillmentPublisher: (any Publisher)? = nil, + precision: Float = 1.0, + perceptualPrecision: Float = 0.98) -> some SwiftUI.View { + preference(key: SnapshotPrecisionPreferenceKey.self, value: precision) + .preference(key: SnapshotPerceptualPrecisionPreferenceKey.self, value: perceptualPrecision) + } +} diff --git a/compound-ios/Sources/Compound/Previews/TestablePreview.swift b/compound-ios/Sources/Compound/Previews/TestablePreview.swift new file mode 100644 index 000000000..eb6ea0f79 --- /dev/null +++ b/compound-ios/Sources/Compound/Previews/TestablePreview.swift @@ -0,0 +1,8 @@ +// +// Copyright 2025 New Vector Ltd +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +protocol TestablePreview { } diff --git a/compound-ios/Sources/Compound/Text Field Styles/SearchFieldStyle.swift b/compound-ios/Sources/Compound/Text Field Styles/SearchFieldStyle.swift new file mode 100644 index 000000000..d3496611c --- /dev/null +++ b/compound-ios/Sources/Compound/Text Field Styles/SearchFieldStyle.swift @@ -0,0 +1,102 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI +import SwiftUIIntrospect + +public extension View { + /// Styles a search bar text field using the Compound design tokens. + /// This modifier is to be used in combination with `.searchable`. + @MainActor + @ViewBuilder + func compoundSearchField() -> some View { + if #available(iOS 26, *) { + self + } else { + introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in + // Uses the navigation stack as .searchField is unreliable when pushing the second search bar, during the create rooms flow. + guard let searchController = navigationController.navigationBar.topItem?.searchController else { return } + + // Ported from Riot iOS as this is the only reliable way to get the exact look we want. + // However this is fragile and tied to gutwrenching the current UISearchBar internals. + let textColor = UIColor.compound.textPrimary + let placeholderColor = UIColor.compound.textSecondary + let textFieldTintColor = UIColor.compound.iconAccentTertiary + let textFieldBackgroundColor = UIColor.compound._bgSubtleSecondaryAlpha + + let searchTextField = searchController.searchBar.searchTextField + + // Magnifying glass icon. + let leftImageView = searchTextField.leftView as? UIImageView + leftImageView?.tintColor = placeholderColor + // Placeholder text. + let placeholderLabel = searchTextField.value(forKey: "placeholderLabel") as? UILabel + placeholderLabel?.textColor = placeholderColor + // Clear button. + let clearButton = searchTextField.value(forKey: "clearButton") as? UIButton + let buttonImage = clearButton?.image(for: .normal)?.withRenderingMode(.alwaysTemplate) + clearButton?.setImage(buttonImage, for: .normal) + clearButton?.tintColor = placeholderColor + + // Text field. + searchTextField.textColor = textColor + searchTextField.backgroundColor = textFieldBackgroundColor + searchTextField.tintColor = textFieldTintColor + + // Hide the effect views so we can use the rounded rect style without any materials. + let effectBackgroundTop = searchTextField.value(forKey: "_effectBackgroundTop") as? UIView + effectBackgroundTop?.isHidden = true + let effectBackgroundBottom = searchTextField.value(forKey: "_effectBackgroundBottom") as? UIView + effectBackgroundBottom?.isHidden = false + } + } + } +} + +// MARK: - Previews + +struct SearchStyle_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + NavigationStack { + List { + ForEach(0..<10, id: \.self) { index in + Text("Item \(index)") + } + } + .listStyle(.plain) + .searchable(text: .constant("")) + .compoundSearchField() + } + .tint(.compound.textActionPrimary) + .previewDisplayName("List") + + NavigationStack { + Form { + Section { + ListRow(label: .plain(title: "Some row"), + kind: .button { }) + } header: { + Text("Settings") + .compoundListSectionHeader() + } + + Section { + ListRow(label: .plain(title: "Some setting"), + kind: .toggle(.constant(true))) + } header: { + Text("More Settings") + .compoundListSectionHeader() + } + } + .compoundList() + .searchable(text: .constant("")) + .compoundSearchField() + } + .tint(.compound.textActionPrimary) + .previewDisplayName("Form") + } +} diff --git a/compound-ios/Tests/CompoundTests/AvatarColorsTests.swift b/compound-ios/Tests/CompoundTests/AvatarColorsTests.swift new file mode 100644 index 000000000..59bd2315c --- /dev/null +++ b/compound-ios/Tests/CompoundTests/AvatarColorsTests.swift @@ -0,0 +1,46 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +@testable import Compound +import SwiftUI +import XCTest + +final class DecorativeColorsTests: XCTestCase { + struct TestCase { + let input: String + private let webOutput: Int + + // remember that web starts the index from 1 while we start from 0 + var output: Int { + webOutput - 1 + } + + init(input: String, webOutput: Int) { + self.input = input + self.webOutput = webOutput + } + } + + func testAvatarColorHash() { + // Match the tests with the web ones for consistency between the two platforms + // https://github.com/element-hq/compound-web/blob/4608dc807c9c904874eac67ff22be3213f4a261d/src/components/Avatar/Avatar.test.tsx#L62 + let testCases: [TestCase] = [ + .init(input: "@bob:example.org", webOutput: 4), + .init(input: "@alice:example.org", webOutput: 3), + .init(input: "@charlie:example.org", webOutput: 5), + .init(input: "@dan:example.org", webOutput: 4), + .init(input: "@elena:example.org", webOutput: 4), + .init(input: "@fanny:example.org", webOutput: 3) + ] + + for testCase in testCases { + XCTAssertEqual(Color.compound.decorativeColor(for: testCase.input), Color.compound.decorativeColors[testCase.output]) + } + } +} diff --git a/compound-ios/Tests/CompoundTests/FontSizeTests.swift b/compound-ios/Tests/CompoundTests/FontSizeTests.swift new file mode 100644 index 000000000..4630d9a85 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/FontSizeTests.swift @@ -0,0 +1,104 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +@testable import Compound +import SwiftUI +import XCTest + +final class FontSizeTests: XCTestCase { + /// Test all system text styles to assert mapping between `Font` and `UIFont`. + func testTextStyle() throws { + let caption2FontSize = FontSize.reflecting(.caption2) + XCTAssertEqual(caption2FontSize?.value, 11) + XCTAssertEqual(caption2FontSize?.style, .caption2) + + let captionFontSize = FontSize.reflecting(.caption) + XCTAssertEqual(captionFontSize?.value, 12) + XCTAssertEqual(captionFontSize?.style, .caption) + + let footnoteFontSize = FontSize.reflecting(.footnote) + XCTAssertEqual(footnoteFontSize?.value, 13) + XCTAssertEqual(footnoteFontSize?.style, .footnote) + + let subheadlineFontSize = FontSize.reflecting(.subheadline) + XCTAssertEqual(subheadlineFontSize?.value, 15) + XCTAssertEqual(subheadlineFontSize?.style, .subheadline) + + let calloutFontSize = FontSize.reflecting(.callout) + XCTAssertEqual(calloutFontSize?.value, 16) + XCTAssertEqual(calloutFontSize?.style, .callout) + + let bodyFontSize = FontSize.reflecting(.body) + XCTAssertEqual(bodyFontSize?.value, 17) + XCTAssertEqual(bodyFontSize?.style, .body) + + let headlineFontSize = FontSize.reflecting(.headline) + XCTAssertEqual(headlineFontSize?.value, 17) + XCTAssertEqual(headlineFontSize?.style, .headline) + + let title3FontSize = FontSize.reflecting(.title3) + XCTAssertEqual(title3FontSize?.value, 20) + XCTAssertEqual(title3FontSize?.style, .title3) + + let title2FontSize = FontSize.reflecting(.title2) + XCTAssertEqual(title2FontSize?.value, 22) + XCTAssertEqual(title2FontSize?.style, .title2) + + let titleFontSize = FontSize.reflecting(.title) + XCTAssertEqual(titleFontSize?.value, 28) + XCTAssertEqual(titleFontSize?.style, .title) + + let largeTitleFontSize = FontSize.reflecting(.largeTitle) + XCTAssertEqual(largeTitleFontSize?.value, 34) + XCTAssertEqual(largeTitleFontSize?.style, .largeTitle) + } + + func testModifiedTextStyle() { + let boldCaptionFontSize = FontSize.reflecting(.caption.bold()) + XCTAssertEqual(boldCaptionFontSize?.value, 12) + XCTAssertEqual(boldCaptionFontSize?.style, .caption) + + let styledTitle = Font.title.width(.compressed).bold().italic().monospaced() + let styledTitleFontSize = FontSize.reflecting(styledTitle) + XCTAssertEqual(styledTitleFontSize?.value, 28) + XCTAssertEqual(styledTitleFontSize?.style, .title) + } + + func testSystemFont() { + let system21FontSize = FontSize.reflecting(.system(size: 21)) + XCTAssertEqual(system21FontSize?.value, 21) + + let boldSystem29FontSize = FontSize.reflecting(.system(size: 29).bold()) + XCTAssertEqual(boldSystem29FontSize?.value, 29) + + let styledSystem33 = Font.system(size: 33).width(.compressed).bold().italic().monospacedDigit() + let styledSystem33FontSize = FontSize.reflecting(styledSystem33) + XCTAssertEqual(styledSystem33FontSize?.value, 33) + } + + func testCustomFont() { + let custom43FontSize = FontSize.reflecting(.custom("Baskerville", size: 43)) + XCTAssertEqual(custom43FontSize?.value, 43) + XCTAssertEqual(custom43FontSize?.style, .body) + + let styledCustom35 = Font.custom("Baskerville", size: 35).weight(.thin).monospaced().italic() + let styledCustom35FontSize = FontSize.reflecting(styledCustom35) + XCTAssertEqual(styledCustom35FontSize?.value, 35) + XCTAssertEqual(styledCustom35FontSize?.style, .body) + } + + func testCustomFontWithTextStyle() { + let customTitle21FontSize = FontSize.reflecting(.custom("Baskerville", size: 21, relativeTo: .title)) + XCTAssertEqual(customTitle21FontSize?.value, 21) + XCTAssertEqual(customTitle21FontSize?.style, .title) + + let styledCustomFootnote15 = Font.custom("Baskerville", size: 15, relativeTo: .footnote).weight(.thin).monospaced().italic() + let styledCustomFootnote15FontSize = FontSize.reflecting(styledCustomFootnote15) + XCTAssertEqual(styledCustomFootnote15FontSize?.value, 15) + XCTAssertEqual(styledCustomFootnote15FontSize?.style, .footnote) + } +} diff --git a/compound-ios/Tests/CompoundTests/GeneratedPreviewTests.swift b/compound-ios/Tests/CompoundTests/GeneratedPreviewTests.swift new file mode 100644 index 000000000..64197d85d --- /dev/null +++ b/compound-ios/Tests/CompoundTests/GeneratedPreviewTests.swift @@ -0,0 +1,112 @@ +// Generated using Sourcery 2.2.6 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + +// swiftlint:disable all +// swiftformat:disable all + +import XCTest +@testable import Compound + +extension PreviewTests { + + // MARK: - PreviewProvider + + func testCompoundButtonStyle() async throws { + for (index, preview) in CompoundButtonStyle_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testCompoundIcon() async throws { + for (index, preview) in CompoundIcon_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testCompoundToggleStyle() async throws { + for (index, preview) in CompoundToggleStyle_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testListInlinePicker() async throws { + for (index, preview) in ListInlinePicker_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testListRowAccessory() async throws { + for (index, preview) in ListRowAccessory_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testListRowButtonStyle() async throws { + for (index, preview) in ListRowButtonStyle_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testListRowLabel() async throws { + for (index, preview) in ListRowLabel_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testListRowLoadingSelection() async throws { + for (index, preview) in ListRowLoadingSelection_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testListRowTrailingSection() async throws { + for (index, preview) in ListRowTrailingSection_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testListRow() async throws { + for (index, preview) in ListRow_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testListTextStyles() async throws { + for (index, preview) in ListTextStyles_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testScaledFrameModifier() async throws { + for (index, preview) in ScaledFrameModifier_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testScaledOffsetModifier() async throws { + for (index, preview) in ScaledOffsetModifier_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testScaledPaddingModifier() async throws { + for (index, preview) in ScaledPaddingModifier_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testSearchStyle() async throws { + for (index, preview) in SearchStyle_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + func testSendButton() async throws { + for (index, preview) in SendButton_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } +} + +// swiftlint:enable all +// swiftformat:enable all diff --git a/compound-ios/Tests/CompoundTests/OverrideColorTests.swift b/compound-ios/Tests/CompoundTests/OverrideColorTests.swift new file mode 100644 index 000000000..0659145ce --- /dev/null +++ b/compound-ios/Tests/CompoundTests/OverrideColorTests.swift @@ -0,0 +1,37 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +@testable import Compound +import XCTest + +class OverrideColorTests: XCTestCase { + func testSwiftUI() { + let colors = CompoundColors() + let tokens = CompoundColorTokens() + XCTAssertEqual(colors.textPrimary, tokens.textPrimary) + + colors.override(\.textPrimary, with: .pink) + XCTAssertEqual(colors.textPrimary, .pink) + + colors.override(\.textPrimary, with: nil) + XCTAssertEqual(colors.textPrimary, tokens.textPrimary) + } + + func testUIKit() { + let colors = CompoundUIColors() + let tokens = CompoundUIColorTokens() + XCTAssertEqual(colors.textPrimary, tokens.textPrimary) + + colors.override(\.textPrimary, with: .systemPink) + XCTAssertEqual(colors.textPrimary, .systemPink) + + colors.override(\.textPrimary, with: nil) + XCTAssertEqual(colors.textPrimary, tokens.textPrimary) + } +} diff --git a/compound-ios/Tests/CompoundTests/PreviewTests.swift b/compound-ios/Tests/CompoundTests/PreviewTests.swift new file mode 100644 index 000000000..3e2a36109 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/PreviewTests.swift @@ -0,0 +1,238 @@ +// +// Copyright 2025 New Vector Ltd +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import Combine +import SwiftUI +import XCTest + +@testable import Compound +@testable import SnapshotTesting + +@MainActor +class PreviewTests: XCTestCase { + private let deviceConfig: ViewImageConfig = .iPhoneX + private let simulatorDevice: String? = "iPhone14,6" // iPhone SE 3rd Generation + private let requiredOSVersion = (major: 18, minor: 5) + private let snapshotDevices = ["iPhone 16", "iPad"] + private var recordMode: SnapshotTestingConfiguration.Record = .missing + + override func setUp() { + super.setUp() + + if ProcessInfo().environment["RECORD_FAILURES"].map(Bool.init) == true { + recordMode = .failed + } + + checkEnvironments() + UIView.setAnimationsEnabled(false) + } + + /// Check environments to avoid problems with snapshots on different devices or OS. + private func checkEnvironments() { + if let simulatorDevice { + let deviceModel = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] + guard deviceModel?.contains(simulatorDevice) ?? false else { + fatalError("\(deviceModel ?? "Unknown") is the wrong one. Switch to using \(simulatorDevice) for these tests.") + } + } + + let osVersion = ProcessInfo().operatingSystemVersion + guard osVersion.majorVersion == requiredOSVersion.major, osVersion.minorVersion == requiredOSVersion.minor else { + fatalError("Switch to iOS \(requiredOSVersion) for these tests.") + } + guard !snapshotDevices.isEmpty else { + fatalError("Specify at least one snapshot device to test on.") + } + } + + // MARK: - Snapshots + + func assertSnapshots(matching preview: _Preview, testName: String = #function, step: Int) async throws { + let preferences = SnapshotPreferences() + + let preferenceReadingView = preview.content + .onPreferenceChange(SnapshotPrecisionPreferenceKey.self) { preferences.precision = $0 } + .onPreferenceChange(SnapshotPerceptualPrecisionPreferenceKey.self) { preferences.perceptualPrecision = $0 } + + // Render an image of the view in order to trigger the preference updates to occur. + let imageRenderer = ImageRenderer(content: preferenceReadingView) + _ = imageRenderer.uiImage + + var sanitizedSuiteName = String(testName.suffix(testName.count - "test".count).dropLast(2)) + sanitizedSuiteName = sanitizedSuiteName.prefix(1).lowercased() + sanitizedSuiteName.dropFirst() + + for deviceName in snapshotDevices { + guard var device = PreviewDevice(rawValue: deviceName).snapshotDevice() else { + fatalError("Unknown device name: \(deviceName)") + } + // Ignore specific device safe area (using the workaround value to fix rendering issues). + device.safeArea = .one + // Ignore specific device display scale + let traits = UITraitCollection(displayScale: 2.0) + + var testName = "" + if let displayName = preview.displayName { + testName = "\(displayName)-\(deviceName)-\(localeCode)" + } else { + testName = "\(deviceName)-\(localeCode)-\(step)" + } + + let isScreen = switch preview.layout { + case .device: true + default: false + } + if let failure = assertSnapshots(matching: preview.content, + name: testName, + isScreen: isScreen, + device: device, + testName: sanitizedSuiteName, + traits: traits, + preferences: preferences) { + XCTFail(failure) + } + } + } + + private var localeCode: String { + if UserDefaults.standard.bool(forKey: "NSDoubleLocalizedStrings") { + return "pseudo" + } + return languageCode + "-" + regionCode + } + + private var languageCode: String { + Locale.current.language.languageCode?.identifier ?? "" + } + + private var regionCode: String { + Locale.current.language.region?.identifier ?? "" + } + + private func assertSnapshots(matching view: AnyView, + name: String?, + isScreen: Bool, + device: ViewImageConfig, + testName: String = #function, + traits: UITraitCollection = .init(), + preferences: SnapshotPreferences) -> String? { + let matchingView = isScreen ? AnyView(view) : AnyView(view + .frame(width: device.size?.width) + .fixedSize(horizontal: false, vertical: true) + ) + + return withSnapshotTesting(record: recordMode) { + verifySnapshot(of: matchingView, + as: .prefireImage(preferences: preferences, + layout: isScreen ? .device(config: device) : .sizeThatFits, + traits: traits), + named: name, + testName: testName) + } + } + + private func wait(for duration: TimeInterval) { + let expectation = XCTestExpectation(description: "Wait") + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + expectation.fulfill() + } + _ = XCTWaiter.wait(for: [expectation], timeout: duration + 1) + } +} + +private class SnapshotPreferences: @unchecked Sendable { + var precision: Float = 1 + var perceptualPrecision: Float = 1 + var fulfillmentPublisher: AnyPublisher? +} + +// MARK: - SnapshotTesting + Extensions + +private extension PreviewDevice { + func snapshotDevice() -> ViewImageConfig? { + switch rawValue { + case "iPhone 16", "iPhone 15", "iPhone 14", "iPhone 13", "iPhone 12", "iPhone 11", "iPhone 10": + return .iPhoneX + case "iPhone 6", "iPhone 6s", "iPhone 7", "iPhone 8": + return .iPhone8 + case "iPhone 6 Plus", "iPhone 6s Plus", "iPhone 8 Plus": + return .iPhone8Plus + case "iPhone SE (1st generation)", "iPhone SE (2nd generation)": + return .iPhoneSe + case "iPad": + return .iPad10_2 + case "iPad Mini": + return .iPadMini + case "iPad Pro 11": + return .iPadPro11 + case "iPad Pro 12.9": + return .iPadPro12_9 + default: return nil + } + } +} + +private extension Snapshotting where Value: SwiftUI.View, Format == UIImage { + static func prefireImage(drawHierarchyInKeyWindow: Bool = false, + preferences: SnapshotPreferences, + layout: SwiftUISnapshotLayout = .sizeThatFits, + traits: UITraitCollection = .init()) -> Snapshotting { + let config: ViewImageConfig + + switch layout { + #if os(iOS) || os(tvOS) + case let .device(config: deviceConfig): + config = deviceConfig + #endif + case .sizeThatFits: + // Make sure to use the workaround safe area insets. + config = .init(safeArea: .one, size: nil, traits: traits) + case let .fixed(width: width, height: height): + let size = CGSize(width: width, height: height) + // Make sure to use the workaround safe area insets. + config = .init(safeArea: .one, size: size, traits: traits) + } + + return SimplySnapshotting(pathExtension: "png", diffing: .prefireImage(preferences: preferences, scale: traits.displayScale)) + .asyncPullback { view in + var config = config + + let controller: UIViewController + + if config.size != nil { + controller = UIHostingController(rootView: view) + } else { + let hostingController = UIHostingController(rootView: view) + + let maxSize = CGSize.zero + config.size = hostingController.sizeThatFits(in: maxSize) + + controller = hostingController + } + + return snapshotView(config: config, + drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, + traits: traits, + view: controller.view, + viewController: controller) + } + } +} + +private extension Diffing where Value == UIImage { + static func prefireImage(preferences: SnapshotPreferences, scale: CGFloat?) -> Diffing { + lazy var originalDiffing = Diffing.image(precision: preferences.precision, perceptualPrecision: preferences.perceptualPrecision, scale: scale) + return Diffing(toData: { originalDiffing.toData($0) }, + fromData: { originalDiffing.fromData($0) }, + diff: { originalDiffing.diff($0, $1) }) + } +} + +private extension UIEdgeInsets { + /// A custom inset that prevents the snapshotting library from rendering the + /// origin at (10000, 10000) which breaks some of our views such as MessageText. + static var one: UIEdgeInsets { UIEdgeInsets(top: 1, left: 1, bottom: 1, right: 1) } +} diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundButtonStyle.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundButtonStyle.iPad-en-US-0.png new file mode 100644 index 000000000..1b7ca13ca --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundButtonStyle.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1522abe037552d058d16a117a5aab3f8533b2a1f874c62fb3a467dbea5a75985 +size 365301 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundButtonStyle.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundButtonStyle.iPhone-16-en-US-0.png new file mode 100644 index 000000000..47dd6cff9 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundButtonStyle.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7495424229f590d4750e1263d942a84def528edba32bd2aee5ba805c9d9bd0bc +size 246031 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Icons-Only-iPad-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Icons-Only-iPad-en-US.png new file mode 100644 index 000000000..228d00a33 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Icons-Only-iPad-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae7bc39ec3c30e24d8ac88c8d606f778802f0d4444ca5c500de1d325614e4021 +size 143736 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Icons-Only-iPhone-16-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Icons-Only-iPhone-16-en-US.png new file mode 100644 index 000000000..3f9642be0 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Icons-Only-iPhone-16-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19c50858911f09d89a9ac7655c12d3d411075e19ae637cc9c7af8d2d74bd6abb +size 95797 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Labels-iPad-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Labels-iPad-en-US.png new file mode 100644 index 000000000..d005d7393 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Labels-iPad-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ded596557ccd9661dcf7b56bd45afec80bf4e70fe44b0b7f5fe82c554e09d54 +size 270756 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Labels-iPhone-16-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Labels-iPhone-16-en-US.png new file mode 100644 index 000000000..7c55c0d65 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Accessibility-Labels-iPhone-16-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ade49dc268c8a7ff62cacbd4d648121fa1fd488ac4f3b32edbd04dfb2191a31 +size 151126 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Buttons-iPad-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Buttons-iPad-en-US.png new file mode 100644 index 000000000..3f1604402 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Buttons-iPad-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6513d85e4d4bd56b1e23e1e6120a1ad1a10f18ae9a216d10e93a0e8fdf024510 +size 27001 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Buttons-iPhone-16-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Buttons-iPhone-16-en-US.png new file mode 100644 index 000000000..9c70ba061 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Buttons-iPhone-16-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:732408e6893cb40b5a8e3a85ebe334fc74aab6edf7306b1af0fb87cc7ff7e797 +size 18848 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Form-iPad-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Form-iPad-en-US.png new file mode 100644 index 000000000..38e407b5b --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Form-iPad-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd5f836f60f9a8b075033402c00f13aaac04062572e42bbc1d1fec04c8505ff3 +size 97606 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Form-iPhone-16-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Form-iPhone-16-en-US.png new file mode 100644 index 000000000..79f54704e --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundIcon.Form-iPhone-16-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3a2b451c5a074f5d6980d2be9376a41c5e19a3a35a0c503da02251a5079856e +size 50751 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundToggleStyle.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundToggleStyle.iPad-en-US-0.png new file mode 100644 index 000000000..fdeb84e8f --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundToggleStyle.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ac4f5f9eb71287f49783e7cf36900ac7c9ff70c54eb29eba55a1c1ad1762f20 +size 91361 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundToggleStyle.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundToggleStyle.iPhone-16-en-US-0.png new file mode 100644 index 000000000..baa90449b --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/compoundToggleStyle.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4c5095c685fc5b68ecb54245b18deeb6036d1f55547546919e1a1e33cb40315 +size 49337 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listInlinePicker.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listInlinePicker.iPad-en-US-0.png new file mode 100644 index 000000000..e8056710e --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listInlinePicker.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3945f7af1ab1a55166b574f58688889b174289c25638de8acf8d9f78c2c9f75f +size 109376 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listInlinePicker.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listInlinePicker.iPhone-16-en-US-0.png new file mode 100644 index 000000000..61bfcee8a --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listInlinePicker.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bdc5025c6c745081e8e2766f54a148a568460ad18e139c0dea9939dff03319a +size 62084 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRow.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRow.iPad-en-US-0.png new file mode 100644 index 000000000..e50752509 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRow.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01a4916af8da7fa798403e4678cd53f7f10352b5b1d87fef8594935bb61e4fe5 +size 370719 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRow.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRow.iPhone-16-en-US-0.png new file mode 100644 index 000000000..01c57a76d --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRow.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fba908f45758e56689c183c3636c2167cf389f1fb9a5c115276de9150949f1dd +size 250067 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowAccessory.Accessories-iPad-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowAccessory.Accessories-iPad-en-US.png new file mode 100644 index 000000000..62eb4d735 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowAccessory.Accessories-iPad-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f441c345728aff436af9f7aaf6c725cc6c303309915f63faac5726e4768ec495 +size 115602 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowAccessory.Accessories-iPhone-16-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowAccessory.Accessories-iPhone-16-en-US.png new file mode 100644 index 000000000..7bf96d2b2 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowAccessory.Accessories-iPhone-16-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b38ad276f9d70ae600cbdb9ccc01861b584e30532f669895e009732838329ee +size 70069 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowButtonStyle.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowButtonStyle.iPad-en-US-0.png new file mode 100644 index 000000000..5ff220607 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowButtonStyle.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c26d84c066dd85bccb5a5e7a08745803ba4de069493bbce5071b90c55d172cc +size 85789 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowButtonStyle.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowButtonStyle.iPhone-16-en-US-0.png new file mode 100644 index 000000000..4fa1964db --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowButtonStyle.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1c2b14d75abcc6529963178e336a730bd899c4ca73b7b8e7f5589a25c2a2859 +size 40657 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLabel.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLabel.iPad-en-US-0.png new file mode 100644 index 000000000..ddc855684 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLabel.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dccffd12a157f5456f479246044e043313c93e80edca6f822c72fc5b3b74b2f7 +size 192255 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLabel.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLabel.iPhone-16-en-US-0.png new file mode 100644 index 000000000..33c1523c8 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLabel.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f94fc77bc6c85dd850148676053ce549a37486d44950254ce5a12497283bf278 +size 135766 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLoadingSelection.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLoadingSelection.iPad-en-US-0.png new file mode 100644 index 000000000..0dbffc4d7 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLoadingSelection.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8470bb9adc78c11996a13a21b954d338f911604bb1370c8484d4ace61c897bed +size 134850 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLoadingSelection.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLoadingSelection.iPhone-16-en-US-0.png new file mode 100644 index 000000000..37b61b9c4 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowLoadingSelection.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a9530ae1b21201bf606b46223ff1717a55c464e2b3e262f2e07031a61e2f0fc +size 100075 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowTrailingSection.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowTrailingSection.iPad-en-US-0.png new file mode 100644 index 000000000..a27667761 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowTrailingSection.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7de1fe2709765858fbdfbbee27796f6899ae4ec3130997637a1f521ed9cec2c3 +size 92210 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowTrailingSection.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowTrailingSection.iPhone-16-en-US-0.png new file mode 100644 index 000000000..d28162310 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listRowTrailingSection.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b81ef4ef0f7c7f701323daa06fe202055de4a32087142fcb1410255a28e7fb06 +size 49332 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.Form-iPad-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.Form-iPad-en-US.png new file mode 100644 index 000000000..f687db8af --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.Form-iPad-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cb82201cb06d09d1a3e3d3ac9b78b0a5686cd1aa08a15445a61116f42bcaf746 +size 102895 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.Form-iPhone-16-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.Form-iPhone-16-en-US.png new file mode 100644 index 000000000..4ec890279 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.Form-iPhone-16-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5122eab1e08d9f3207410669d7ff1076f02b7df91c1d3cc49356dc7fb10fb705 +size 57230 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.List-iPad-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.List-iPad-en-US.png new file mode 100644 index 000000000..01b328341 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.List-iPad-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e9e66de21b08f5460d34a27026c7f80bf2fa30157920dd8576c222848b05408b +size 86910 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.List-iPhone-16-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.List-iPhone-16-en-US.png new file mode 100644 index 000000000..172b4fd32 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/listTextStyles.List-iPhone-16-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b4a5b177ee1d57b7848eafed1d38a3768bf3310393802374244f76de48a82a2 +size 40995 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledFrameModifier.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledFrameModifier.iPad-en-US-0.png new file mode 100644 index 000000000..805dc9237 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledFrameModifier.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37c1b4ef394ebf21c35eb8dba7dd41cff59b264aec57de0fa8dcc0b368eec3b0 +size 92691 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledFrameModifier.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledFrameModifier.iPhone-16-en-US-0.png new file mode 100644 index 000000000..f9b44afc1 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledFrameModifier.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73953b850cad43b8d09628143d3220441877bc3485dddaf734f14a3469778e8c +size 51105 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledOffsetModifier.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledOffsetModifier.iPad-en-US-0.png new file mode 100644 index 000000000..747571366 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledOffsetModifier.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dee1d6e5f05fefaae3f2cdb8c5d7d942b23f3e5244a9c8589c6cfee1280c2b53 +size 85357 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledOffsetModifier.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledOffsetModifier.iPhone-16-en-US-0.png new file mode 100644 index 000000000..820e4dfd7 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledOffsetModifier.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc49e6cf9dd0490434ee5a189240fe9e45688e0843755ae681a89d35b716944e +size 44655 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledPaddingModifier.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledPaddingModifier.iPad-en-US-0.png new file mode 100644 index 000000000..636321b32 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledPaddingModifier.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f801b1baf3852a280e9edd8c21879f95c933b1f562fdf7db72b7dbcafc71925b +size 112184 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledPaddingModifier.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledPaddingModifier.iPhone-16-en-US-0.png new file mode 100644 index 000000000..c9a9cc8a1 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/scaledPaddingModifier.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:721e0439a8ed8dbdc201cb618ddf157ac49dbf8f165a84cba932324b24f98252 +size 69586 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.Form-iPad-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.Form-iPad-en-US.png new file mode 100644 index 000000000..cd82ee333 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.Form-iPad-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e410e13b6bfb668cc3593c746ef0bd48673ab32283630826130cabcc43d6f43 +size 100011 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.Form-iPhone-16-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.Form-iPhone-16-en-US.png new file mode 100644 index 000000000..268b0e3bf --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.Form-iPhone-16-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9cbb08c9fe6df9b564c93192aee5c655bf5c7ec69711fae822c7aea9e9a785a3 +size 50053 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.List-iPad-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.List-iPad-en-US.png new file mode 100644 index 000000000..9c150c3dd --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.List-iPad-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ffa8d0056bb4cee07ad513405eb3b3903e21cfbd945866c354b7e67fbdffe19 +size 92428 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.List-iPhone-16-en-US.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.List-iPhone-16-en-US.png new file mode 100644 index 000000000..152bace41 --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/searchStyle.List-iPhone-16-en-US.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd3e0d853e76b65eab44bd0e52d0c468c4fcaf0f60021d74d68949628238acb5 +size 49150 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/sendButton.iPad-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/sendButton.iPad-en-US-0.png new file mode 100644 index 000000000..87fe2694c --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/sendButton.iPad-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa1eadbbf0b7afd190fc65855ff81650216fb4bbc188802a93f210a9a2c2a896 +size 79859 diff --git a/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/sendButton.iPhone-16-en-US-0.png b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/sendButton.iPhone-16-en-US-0.png new file mode 100644 index 000000000..7aaab594b --- /dev/null +++ b/compound-ios/Tests/CompoundTests/__Snapshots__/PreviewTests/sendButton.iPhone-16-en-US-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7404a991f29fb33fe7d3d7fbcb102d2e89ed4975d6cb1e125e4448a03a885e8a +size 39549 diff --git a/compound-ios/Tools/Sourcery/PreviewTests.stencil b/compound-ios/Tools/Sourcery/PreviewTests.stencil new file mode 100644 index 000000000..d706c192d --- /dev/null +++ b/compound-ios/Tools/Sourcery/PreviewTests.stencil @@ -0,0 +1,46 @@ +// swiftlint:disable all +// swiftformat:disable all + +import XCTest +@testable import Compound +{% if argument.mainTarget %} +@testable import {{ argument.mainTarget }} +{% endif %} +{% for import in argument.imports %} +{% if import != "last" %} +import {{ import }} +{% endif %} +{% endfor %} +{% for import in argument.testableImports %} +{% if import != "last" %} +@testable import {{ import }} +{% endif %} +{% endfor %} + +extension PreviewTests { + {% if argument.file %} + + private var file: StaticString { .init(stringLiteral: "{{ argument.file }}") } + {% endif %} + + // MARK: - PreviewProvider + + {% for type in types.types where (type.implements.TestablePreview or type.based.TestablePreview or type|annotated:"TestablePreview") and type.name != "TestablePreview" %} + func test{{ type.name|replace:"_Previews", "" }}() async throws { + for (index, preview) in {{ type.name }}._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + {%- if not forloop.last %} + + {% endif %} + {% endfor %} + {% if argument.previewsMacros %} + // MARK: - Macros + + {{ argument.previewsMacros }} + {% endif %} +} + +// swiftlint:enable all +// swiftformat:enable all diff --git a/compound-ios/Tools/Sourcery/PreviewTestsConfig.yml b/compound-ios/Tools/Sourcery/PreviewTestsConfig.yml new file mode 100644 index 000000000..c7d4b6127 --- /dev/null +++ b/compound-ios/Tools/Sourcery/PreviewTestsConfig.yml @@ -0,0 +1,7 @@ +sources: + include: + - ../../Sources +templates: + - PreviewTests.stencil +output: + ../../Tests/CompoundTests/GeneratedPreviewTests.swift diff --git a/project.yml b/project.yml index eeb54c51f..10c81c4ba 100644 --- a/project.yml +++ b/project.yml @@ -71,9 +71,7 @@ packages: exactVersion: 25.09.19-2 # path: ../matrix-rust-sdk Compound: - url: https://github.com/element-hq/compound-ios - revision: 16f4544d2823d590401f13da260e56b8674b66b3 - # path: ../compound-ios + path: compound-ios AnalyticsEvents: url: https://github.com/matrix-org/matrix-analytics-events minorVersion: 0.29.2