diff --git a/MisticaCatalog/MisticaCatalog.xcodeproj/project.pbxproj b/MisticaCatalog/MisticaCatalog.xcodeproj/project.pbxproj index 6579d49e..e9ceb59b 100644 --- a/MisticaCatalog/MisticaCatalog.xcodeproj/project.pbxproj +++ b/MisticaCatalog/MisticaCatalog.xcodeproj/project.pbxproj @@ -91,6 +91,7 @@ BB9A70292CE0D94900B2767F /* CardList.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9A70282CE0D93D00B2767F /* CardList.swift */; }; BB9A702B2CE0E34600B2767F /* PosterCardCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB9A702A2CE0E34300B2767F /* PosterCardCatalogView.swift */; }; BB9A702E2CE3B3BE00B2767F /* airpods.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = BB9A702D2CE3B3BE00B2767F /* airpods.mp4 */; }; + BBEAB72E2CFF5A0C00AE8D8F /* SnapCardCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBEAB72D2CFF5A0600AE8D8F /* SnapCardCatalogView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -180,6 +181,7 @@ BB9A70282CE0D93D00B2767F /* CardList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardList.swift; sourceTree = ""; }; BB9A702A2CE0E34300B2767F /* PosterCardCatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterCardCatalogView.swift; sourceTree = ""; }; BB9A702D2CE3B3BE00B2767F /* airpods.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = airpods.mp4; sourceTree = ""; }; + BBEAB72D2CFF5A0600AE8D8F /* SnapCardCatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapCardCatalogView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -231,6 +233,7 @@ 18E48553287F19EB0052A6F2 /* RadioButtonCatalogView.swift */, 244D00C52C491D4600424AA5 /* SkeletonsCatalogView.swift */, 18E48550287F19EB0052A6F2 /* SnackbarCatalogView.swift */, + BBEAB72D2CFF5A0600AE8D8F /* SnapCardCatalogView.swift */, 18E4854E287F19EB0052A6F2 /* StepperCatalogVIew.swift */, 18E48559287F19EB0052A6F2 /* TabsCatalogView.swift */, 18E4855B287F19EB0052A6F2 /* TagCatalogView.swift */, @@ -570,6 +573,7 @@ 18E4858A287F19EB0052A6F2 /* BadgeCatalogView.swift in Sources */, 18E48583287F19EB0052A6F2 /* RadioButtonCatalogView.swift in Sources */, BB9A702B2CE0E34600B2767F /* PosterCardCatalogView.swift in Sources */, + BBEAB72E2CFF5A0C00AE8D8F /* SnapCardCatalogView.swift in Sources */, 18E4858F287F19EB0052A6F2 /* UICatalogScrollContentIndicatorViewController.swift in Sources */, 18E4857B287F19EB0052A6F2 /* CatalogList.swift in Sources */, 18E48586287F19EB0052A6F2 /* ButtonCatalogView.swift in Sources */, diff --git a/MisticaCatalog/Source/Catalog/Cards/CardList.swift b/MisticaCatalog/Source/Catalog/Cards/CardList.swift index aa044a1e..619415fd 100644 --- a/MisticaCatalog/Source/Catalog/Cards/CardList.swift +++ b/MisticaCatalog/Source/Catalog/Cards/CardList.swift @@ -66,7 +66,7 @@ private extension CardRow { case .posterCard: PosterCardCatalogView() case .snapCard: - notImplementedView + SnapCardCatalogView() } } diff --git a/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/PosterCardCatalogView.swift b/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/PosterCardCatalogView.swift index e2762af0..b1303969 100644 --- a/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/PosterCardCatalogView.swift +++ b/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/PosterCardCatalogView.swift @@ -178,7 +178,7 @@ struct PosterCardCatalogView: View { NavigationLink("Show Poster Card") { posterCard() .padding(.horizontal, 16) - .navigationBarTitle("DataCard") + .navigationBarTitle("PosterCard") .navigationBarTitleDisplayMode(.inline) } } diff --git a/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/SnapCardCatalogView.swift b/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/SnapCardCatalogView.swift new file mode 100644 index 00000000..eda3c16c --- /dev/null +++ b/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/SnapCardCatalogView.swift @@ -0,0 +1,134 @@ +// +// SnapCardCatalogView.swift +// +// Made with ❤️ by Novum +// +// Copyright © Telefonica. All rights reserved. +// + +import Foundation +import MisticaSwiftUI +import SwiftUI + +struct SnapCardCatalogView: View { + @State var title: String = "AirPods 4" + @State var subTitle: String = "" + @State var description: String = "The next evolution of sound and comfort" + @State var aspectRatio: AspectRatio = .ratio7to10 + @State var assetType: AssetType = .none + @State var hasSlot: Bool = false + @State var isInverse: Bool = false + + enum AspectRatio: String, CaseIterable, Identifiable { + var id: Self { self } + + case ratio1to1 = "1:1" + case ratio7to10 = "7:10" + case ratio9to10 = "9:10" + case ratio16to9 = "16:9" + + var toSnapCardAspectRatio: SnapCardAspectRatio { + switch self { + case .ratio1to1: .ratio1to1 + case .ratio7to10: .ratio7to10 + case .ratio9to10: .ratio9to10 + case .ratio16to9: .ratio16to9 + } + } + } + + enum AssetType: String, CaseIterable, Identifiable, Equatable { + var id: Self { self } + + case none + case icon = "Icon" + case circledIcon = "Circled icon" + case avatar = "Avatar" + + func toSnapCardAssetType() -> SnapCardAssetType { + switch self { + case .none: + return .none + case .icon: + return .icon(image: Image(systemName: "apple.logo"), foregroundColor: nil, backgroundColor: nil) + case .circledIcon: + return .icon(image: Image(systemName: "apple.logo"), foregroundColor: .brand, backgroundColor: .brandLow) + case .avatar: + return .avatar(Image("netflix-logo")) + } + } + } + + var body: some View { + VStack { + List { + section("Title") { TextField("Title", text: $title) } + section("Subtitle") { TextField("Subtitle", text: $subTitle) } + section("Description") { TextField("Description", text: $description) } + section("Aspect ratio") { + Picker("Aspect Ratio", selection: $aspectRatio) { + ForEach(AspectRatio.allCases) { + Text($0.rawValue.capitalized) + } + } + .pickerStyle(.segmented) + } + section("Asset type") { + Picker("Asset type", selection: $assetType) { + ForEach(AssetType.allCases) { + Text($0.rawValue.capitalized) + } + } + } + section("Options") { + VStack { + Toggle("Has slot", isOn: $hasSlot) + Toggle("Inverse", isOn: $isInverse) + } + } + + NavigationLink("Show Snap Card") { + snapCard() + .padding(.horizontal, 16) + .navigationBarTitle("SnapCard") + .navigationBarTitleDisplayMode(.inline) + } + } + } + } + + @ViewBuilder + func snapCard() -> some View { + VStack { + SnapCard( + themeVariant: isInverse ? .inverse : .none, + aspectRatio: aspectRatio.toSnapCardAspectRatio, + assetType: assetType.toSnapCardAssetType(), + title: title, + subTitle: subTitle.isEmpty ? nil : subTitle, + description: description.isEmpty ? nil : description, + slot: { + if hasSlot { + HStack(alignment: .center) { + Spacer() + Text("This is a slot") + Spacer() + } + .padding() + .background(Color.green) + } else { + EmptyView() + } + } + ) + } + .padding() + } +} + +#Preview { + NavigationView { + SnapCardCatalogView() + } + .misticaNavigationViewStyle() +} diff --git a/Sources/MisticaSwiftUI/Components/Cards/README.md b/Sources/MisticaSwiftUI/Components/Cards/README.md index ca7022be..244b63d9 100644 --- a/Sources/MisticaSwiftUI/Components/Cards/README.md +++ b/Sources/MisticaSwiftUI/Components/Cards/README.md @@ -8,6 +8,9 @@ * [How to use a PosterCard](#how-to-use-a-postercard) * [Adding extra content to a PosterCard](#adding-extra-content-to-a-postercard) +* [SnapCard](#snapcard) + * [How to use a SnapCard](#how-to-use-a-snapcard) + * [Accessibility](#accessibility) ## DataCard @@ -57,7 +60,7 @@ You can use the default initializers depending on your needs. For example, a bas ```swift PosterCard( - mediaType: .image, + mediaType: .image(Image("airpods"), topActions: .dismiss {}), title: title, subtitle: subtitle, description: description @@ -83,7 +86,7 @@ The extra View will be placed below the descriptionTitle property, keeping the s ```swift PosterCard( - mediaType: .image, + mediaType: .image(Image("airpods"), topActions: .dismiss {}), title: title, subtitle: subtitle, description: description, @@ -133,3 +136,25 @@ PosterCard( ### Conclusion `PosterCard` is a highly flexible and customizable component for displaying various types of media in your SwiftUI apps. Whether you need a simple image card or a fully interactive video card, `PosterCard` provides the tools to make it happen. + +## SnapCard + +Snap Cards are quick content-minimal elements. Their aim is to be used to read content quickly, which acts as an entry point to more detailed information. + +If you're using multiple cards on the same screen, use the same type to keep the overall visual hierarchy. + +![SnapCard](./docs/images/snap-card.png) + +### How to use a SnapCard + +You can use the default initializers depending on your needs. For example, a basic configuration for an image-based PosterCard: + +```swift +SnapCard( + assetType: .avatar(Image("avatar-icon")), + title: "title", + subTitle: "subtitle", + description: "description", + slot: { Text("Extra Content!") } +) +``` diff --git a/Sources/MisticaSwiftUI/Components/Cards/SnapCard/SnapCard.swift b/Sources/MisticaSwiftUI/Components/Cards/SnapCard/SnapCard.swift new file mode 100644 index 00000000..98a37d71 --- /dev/null +++ b/Sources/MisticaSwiftUI/Components/Cards/SnapCard/SnapCard.swift @@ -0,0 +1,414 @@ +// +// SnapCard.swift +// +// Made with ❤️ by Novum +// +// Copyright © Telefonica. All rights reserved. +// + +import SwiftUI + +private enum Constants { + static let spacing = 16.0 + static let shortSpacing = 4.0 + static let minHeight = 80.0 + static let assetTypeImageSize: CGFloat = 40 + static let assetTypeIcontSize: CGFloat = 24 + static let buttonAnimationDuration: CGFloat = 0.1 +} + +/// Snap Cards are quick content-minimal elements. Their aim is to be used to read content quickly, which acts +/// as an entry point to more detailed information. +/// - Generic `Slot`: A customizable subview included in the card. +@available(iOS 13.0, macOS 10.15, tvOS 16.0, watchOS 6.0, *) +public struct SnapCard: View where Slot: View { + /// The theme variant of the card + private var themeVariant: SnapCardThemeVariant + + /// The aspect ratio of the card's media content. + public let aspectRatio: SnapCardAspectRatio + + /// An asset type (e.g., image or icon) + public let assetType: SnapCardAssetType + + /// A short and clear label that states the card's main message. It should not exceed 2 lines. + private let title: String + + /// Title complementary info, it should be brief and should not exceed 2 lines. + private let subTitle: String? + + /// Optional description text displayed below the subtitle. + private let description: String? + + /// A customizable slot view displayed at the bottom of the card. + public let slot: Slot + + private let action: SnapCardCallback + + public var titleLineLimit: Int? + public var subTitleLineLimit: Int? + public var descriptionLineLimit: Int? + + private var assetAccessibilityLabel: String? + private var assetAccessibilityIdentifier: String? + private var titleAccessibilityLabel: String? + private var titleAccessibilityIdentifier: String? + private var subTitleAccessibilityLabel: String? + private var subTitleAccessibilityIdentifier: String? + private var descriptionAccessibilityLabel: String? + private var descriptionAccessibilityIdentifier: String? + + // MARK: - Initializer + + /// Creates an instance of `SnapCard`. + /// + /// - Parameters: + /// - themeVariant: Theme variant + /// - aspectRatio: The aspect ratio of the card. + /// - assetType: An optional asset type for the header. + /// - title: Title of the card. + /// - titleLineLimit: Line limit of the title of the card. + /// - subTitle: Subtitle text. + /// - subTitleLineLimit: Line limit of the subtitle text. + /// - description: Description text. + /// - descriptionLineLimit: Line limit of the description text. + /// - slot: A customizable slot view. + /// - action: An action to handle tap action + public init( + themeVariant: SnapCardThemeVariant = .none, + aspectRatio: SnapCardAspectRatio = .ratio7to10, + assetType: SnapCardAssetType, + title: String, + titleLineLimit: Int? = nil, + subTitle: String? = nil, + subTitleLineLimit: Int? = nil, + description: String? = nil, + descriptionLineLimit: Int? = nil, + @ViewBuilder slot: () -> Slot = { EmptyView() }, + action: @escaping SnapCardCallback = {} + ) { + self.themeVariant = themeVariant + self.aspectRatio = aspectRatio + self.assetType = assetType + self.title = title + self.titleLineLimit = titleLineLimit + self.subTitle = subTitle + self.subTitleLineLimit = subTitleLineLimit + self.description = description + self.descriptionLineLimit = descriptionLineLimit + self.slot = slot() + self.action = action + } + + // MARK: - View Body + + public var body: some View { + Button(action: { action() }) { + VStack(alignment: .leading, spacing: .zero) { + VStack(alignment: .leading, spacing: .zero) { + assetView + .accessibilityLabel(assetAccessibilityLabel) + .accessibilityIdentifier(assetAccessibilityIdentifier) + + VStack(alignment: .leading, spacing: Constants.shortSpacing) { + Text(title) + .font(.textPreset2(weight: .medium)) + .foregroundColor(textPrimaryColor) + .lineLimit(titleLineLimit) + .accessibilityLabel(titleAccessibilityLabel) + .accessibilityIdentifier(titleAccessibilityIdentifier) + .fixedSize(horizontal: false, vertical: true) + + if let subTitle { + Text(subTitle) + .font(.textPreset2(weight: .regular)) + .foregroundColor(textPrimaryColor) + .lineLimit(subTitleLineLimit) + .accessibilityLabel(subTitleAccessibilityLabel) + .accessibilityIdentifier(subTitleAccessibilityIdentifier) + .fixedSize(horizontal: false, vertical: true) + } + + if let description { + Text(description) + .font(.textPreset2(weight: .regular)) + .foregroundColor(textSecondaryColor) + .lineLimit(descriptionLineLimit) + .multilineTextAlignment(.leading) + .accessibilityLabel(subTitleAccessibilityLabel) + .accessibilityIdentifier(subTitleAccessibilityIdentifier) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + + if hasSlotView { + slot + .padding(.top, Constants.spacing) + } + } + .padding(Constants.spacing) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .aspectRatio(aspectRatio.value, contentMode: .fill) + .frame(minHeight: Constants.minHeight) + .background(backgroundView) + .fixedSize(horizontal: false, vertical: true) + .border(radiusStyle: .container, borderColor: borderColor, lineWidth: borderLineWidth) + } + .buttonStyle(SnapCardButtonStyle(backgroundPressedColor: backgroundPressedColor)) + } + + @ViewBuilder + private var assetView: some View { + switch assetType { + case let .avatar(image, borderColor, lineWidth): + image + .resizable() + .scaledToFill() + .frame(width: Constants.assetTypeImageSize, height: Constants.assetTypeImageSize) + .border(radiusStyle: .avatar, borderColor: borderColor, lineWidth: lineWidth) + .round(radiusStyle: .avatar) + .padding(.bottom, Constants.spacing) + + case let .icon(image, foregroundColor, backgroundColor): + ZStack { + if let backgroundColor = backgroundColor { + Circle().fill(backgroundColor) + .frame(width: Constants.assetTypeImageSize, height: Constants.assetTypeImageSize) + } + + image + .resizable() + .foregroundColor(foregroundColor) + .scaledToFit() + .frame(width: Constants.assetTypeIcontSize, height: Constants.assetTypeIcontSize) + } + .padding(.bottom, Constants.spacing) + + case .none: + EmptyView() + } + } +} + +private extension SnapCard { + var hasSlotView: Bool { + !(slot is EmptyView) + } + + var textPrimaryColor: Color { + switch themeVariant { + case .none, + .overAlternative: + return .textPrimary + case .inverse, + .overInverse: + return .textPrimaryInverse + } + } + + var textSecondaryColor: Color { + switch themeVariant { + case .none, + .overAlternative: + return .textSecondary + case .inverse, + .overInverse: + return .textSecondaryInverse + } + } + + @ViewBuilder + var backgroundView: some View { + switch themeVariant { + case .none, + .overInverse, + .overAlternative: + Color.backgroundContainer + case .inverse: + misticaColorView(.backgroundContainerBrand) + } + } + + var backgroundPressedColor: Color { + switch themeVariant { + case .none, + .overInverse, + .overAlternative: + return .backgroundContainerPressed + case .inverse: + return .backgroundContainerBrandPressed + } + } + + var borderColor: Color { + switch themeVariant { + case .none, + .overAlternative: + return .border + case .inverse, + .overInverse: + return .clear + } + } + + var borderLineWidth: CGFloat { + switch themeVariant { + case .none, + .overAlternative: + return 1.0 + case .inverse, + .overInverse: + return .zero + } + } +} + +public extension SnapCard { + func assetAccessibilityLabel(_ assetAccessibilityLabel: String?) -> Self { + var view = self + view.assetAccessibilityLabel = assetAccessibilityLabel + return view + } + + func assetAccessibilityIdentifier(_ assetAccessibilityIdentifier: String?) -> Self { + var view = self + view.assetAccessibilityIdentifier = assetAccessibilityIdentifier + return view + } + + func titleAccessibilityLabel(_ titleAccessibilityLabel: String?) -> Self { + var view = self + view.titleAccessibilityLabel = titleAccessibilityLabel + return view + } + + func titleAccessibilityIdentifier(_ titleAccessibilityIdentifier: String?) -> Self { + var view = self + view.titleAccessibilityIdentifier = titleAccessibilityIdentifier + return view + } + + func subTitleAccessibilityLabel(_ subTitleAccessibilityLabel: String?) -> Self { + var view = self + view.subTitleAccessibilityLabel = subTitleAccessibilityLabel + return view + } + + func subTitleAccessibilityIdentifier(_ subTitleAccessibilityIdentifier: String?) -> Self { + var view = self + view.subTitleAccessibilityIdentifier = subTitleAccessibilityIdentifier + return view + } + + func descriptionAccessibilityLabel(_ descriptionAccessibilityLabel: String?) -> Self { + var view = self + view.descriptionAccessibilityLabel = descriptionAccessibilityLabel + return view + } + + func descriptionAccessibilityIdentifier(_ descriptionAccessibilityIdentifier: String?) -> Self { + var view = self + view.descriptionAccessibilityIdentifier = descriptionAccessibilityIdentifier + return view + } +} + +// MARK: - Public Types + +/// A callback type for handling user interactions on `SnapCard`. +public typealias SnapCardCallback = () -> Void + +public enum SnapCardThemeVariant { + case none + case inverse + case overInverse + case overAlternative +} + +/// Defines aspect ratio options for the `PosterCard`. +public enum SnapCardAspectRatio { + /// A 1:1 aspect ratio. + case ratio1to1 + + /// A 7:10 aspect ratio. + case ratio7to10 + + /// A 9:10 aspect ratio. + case ratio9to10 + + /// A 16:9 aspect ratio. + case ratio16to9 + + /// A custom aspect ratio. + case custom(CGFloat) + + /// Returns the numerical value of the aspect ratio. + public var value: CGFloat { + switch self { + case .ratio1to1: + return 1 + case .ratio7to10: + return 7.0 / 10.0 + case .ratio9to10: + return 9.0 / 10.0 + case .ratio16to9: + return 16.0 / 9.0 + case let .custom(ratio): + return ratio + } + } +} + +/// Represents the asset type in a `SnapCard`. +public enum SnapCardAssetType { + /// No asset. + case none + + /// An icon with optional foreground and background colors. + case icon(image: Image, foregroundColor: Color? = nil, backgroundColor: Color? = nil) + + /// A standalone avatar image. + case avatar(Image, borderColor: Color = .border, borderSize: CGFloat = 1.0) +} + +// MARK: - Private Extensions + +private struct SnapCardButtonStyle: ButtonStyle { + let backgroundPressedColor: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .snapCardButtonStyle( + shouldAnimate: configuration.isPressed, + backgroundColor: configuration.isPressed ? backgroundPressedColor : .clear + ) + } +} + +private extension View { + func snapCardButtonStyle( + shouldAnimate: Bool, + backgroundColor: Color + ) -> some View { + overlay( + backgroundColor + .round(radiusStyle: .container) + ) + .animation(.easeInOut(duration: Constants.buttonAnimationDuration), value: shouldAnimate) + } + + @ViewBuilder + func conditionalModifier( + _ condition: Bool, + transform: (Self) -> Content + ) -> some View { + if condition { + transform(self) + } else { + self + } + } +} diff --git a/Sources/MisticaSwiftUI/Components/Cards/docs/images/snap-card.png b/Sources/MisticaSwiftUI/Components/Cards/docs/images/snap-card.png new file mode 100644 index 00000000..21267391 Binary files /dev/null and b/Sources/MisticaSwiftUI/Components/Cards/docs/images/snap-card.png differ