From e99d29539843b3aacada462645ef6a51b53badc0 Mon Sep 17 00:00:00 2001 From: Alejandro Ruiz Date: Tue, 23 Jul 2024 17:16:33 +0200 Subject: [PATCH] feat(Skeletons): added Mistica Skeletons components for SwiftUI and UIKit --- .../MisticaCatalog.xcodeproj/project.pbxproj | 10 +- .../Rows/Skeleton.imageset/Contents.json | 22 +++ .../Rows/Skeleton.imageset/Skeleton.svg | 5 + .../Rows/Skeleton.imageset/Skeleton_Dark.svg | 5 + .../Source/Catalog/CatalogList.swift | 4 + .../Source/Catalog/CatalogRow.swift | 5 + .../UICatalogSkeletonsViewController.swift | 103 +++++++++++ .../Components/SkeletonsCatalogView.swift | 54 ++++++ .../Source/Common/CatalogAssets.swift | 1 + .../Components/Skeleton/SkeletonView.swift | 174 ++++++++++++++++++ .../Components/Skeletons/Skeleton.swift | 131 +++++++++++++ .../UI/SkeletonTests.swift | 75 ++++++++ .../SkeletonTests/testCircleSkeleton.1.png | Bin 0 -> 1842 bytes .../SkeletonTests/testLineSkeleton.1.png | Bin 0 -> 1104 bytes .../SkeletonTests/testRectangleSkeleton.1.png | Bin 0 -> 14179 bytes .../SkeletonTests/testRowSkeleton.1.png | Bin 0 -> 4391 bytes .../SkeletonTests/testTextSkeleton.1.png | Bin 0 -> 5000 bytes .../testTextSkeletonWithCustomLines.1.png | Bin 0 -> 8668 bytes Tests/MisticaTests/UI/SkeletonTests.swift | 87 +++++++++ .../SkeletonTests/testCircleSkeleton.1.png | Bin 0 -> 2204 bytes .../SkeletonTests/testLineSkeleton.1.png | Bin 0 -> 1128 bytes .../SkeletonTests/testRectangleSkeleton.1.png | Bin 0 -> 14952 bytes .../SkeletonTests/testRowSkeleton.1.png | Bin 0 -> 5286 bytes .../SkeletonTests/testTextSkeleton.1.png | Bin 0 -> 5002 bytes .../testTextSkeletonWithCustomLines.1.png | Bin 0 -> 8745 bytes 25 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Contents.json create mode 100644 MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Skeleton.svg create mode 100644 MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Skeleton_Dark.svg create mode 100644 MisticaCatalog/Source/Catalog/Mistica/Components/UICatalogSkeletonsViewController.swift create mode 100644 MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/SkeletonsCatalogView.swift create mode 100644 Sources/Mistica/Components/Skeleton/SkeletonView.swift create mode 100644 Sources/MisticaSwiftUI/Components/Skeletons/Skeleton.swift create mode 100644 Tests/MisticaSwiftUITests/UI/SkeletonTests.swift create mode 100644 Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testCircleSkeleton.1.png create mode 100644 Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testLineSkeleton.1.png create mode 100644 Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testRectangleSkeleton.1.png create mode 100644 Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testRowSkeleton.1.png create mode 100644 Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testTextSkeleton.1.png create mode 100644 Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testTextSkeletonWithCustomLines.1.png create mode 100644 Tests/MisticaTests/UI/SkeletonTests.swift create mode 100644 Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testCircleSkeleton.1.png create mode 100644 Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testLineSkeleton.1.png create mode 100644 Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testRectangleSkeleton.1.png create mode 100644 Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testRowSkeleton.1.png create mode 100644 Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testTextSkeleton.1.png create mode 100644 Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testTextSkeletonWithCustomLines.1.png diff --git a/MisticaCatalog/MisticaCatalog.xcodeproj/project.pbxproj b/MisticaCatalog/MisticaCatalog.xcodeproj/project.pbxproj index 2626e471f..b85a5b8c9 100644 --- a/MisticaCatalog/MisticaCatalog.xcodeproj/project.pbxproj +++ b/MisticaCatalog/MisticaCatalog.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ 18E485A4287F19EB0052A6F2 /* UICatalogCroutonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E48575287F19EB0052A6F2 /* UICatalogCroutonViewController.swift */; }; 18E485A5287F19EB0052A6F2 /* UICatalogBadgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E48576287F19EB0052A6F2 /* UICatalogBadgeViewController.swift */; }; 18E485A6287F19EB0052A6F2 /* UICatalogHeaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E48577287F19EB0052A6F2 /* UICatalogHeaderViewController.swift */; }; + 244D00C62C491D4600424AA5 /* SkeletonsCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244D00C52C491D4600424AA5 /* SkeletonsCatalogView.swift */; }; + 244D00C82C49392700424AA5 /* UICatalogSkeletonsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 244D00C72C49392700424AA5 /* UICatalogSkeletonsViewController.swift */; }; 392E03DC28C6153C0081780B /* UICatalogSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 392E03DB28C6153C0081780B /* UICatalogSheetViewController.swift */; }; 3968C75E28C9E19600561194 /* UIStepperTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3968C75D28C9E19600561194 /* UIStepperTableViewCell.swift */; }; 84038E0A2C38382E003E90F6 /* Telefonica Sans Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = 84038E092C38382E003E90F6 /* Telefonica Sans Regular.otf */; }; @@ -133,6 +135,8 @@ 18E48575287F19EB0052A6F2 /* UICatalogCroutonViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UICatalogCroutonViewController.swift; sourceTree = ""; }; 18E48576287F19EB0052A6F2 /* UICatalogBadgeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UICatalogBadgeViewController.swift; sourceTree = ""; }; 18E48577287F19EB0052A6F2 /* UICatalogHeaderViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UICatalogHeaderViewController.swift; sourceTree = ""; }; + 244D00C52C491D4600424AA5 /* SkeletonsCatalogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonsCatalogView.swift; sourceTree = ""; }; + 244D00C72C49392700424AA5 /* UICatalogSkeletonsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICatalogSkeletonsViewController.swift; sourceTree = ""; }; 392E03DB28C6153C0081780B /* UICatalogSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICatalogSheetViewController.swift; sourceTree = ""; }; 3968C75D28C9E19600561194 /* UIStepperTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIStepperTableViewCell.swift; sourceTree = ""; }; 84038E092C38382E003E90F6 /* Telefonica Sans Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Telefonica Sans Regular.otf"; sourceTree = ""; }; @@ -218,6 +222,7 @@ 18E4855B287F19EB0052A6F2 /* TagCatalogView.swift */, 18E4855C287F19EB0052A6F2 /* ListCatalogView.swift */, 1822CA1929E821D400980D47 /* HeaderCatalogView.swift */, + 244D00C52C491D4600424AA5 /* SkeletonsCatalogView.swift */, ); path = Components; sourceTree = ""; @@ -257,6 +262,7 @@ 18E48576287F19EB0052A6F2 /* UICatalogBadgeViewController.swift */, 18E48577287F19EB0052A6F2 /* UICatalogHeaderViewController.swift */, 392E03DB28C6153C0081780B /* UICatalogSheetViewController.swift */, + 244D00C72C49392700424AA5 /* UICatalogSkeletonsViewController.swift */, ); path = Components; sourceTree = ""; @@ -314,8 +320,8 @@ B8F9902A2546C98600DFBFE9 /* AppDelegate.swift */, B8F990152546C98600DFBFE9 /* MisticaCatalogApp.swift */, B8F9902B2546C98600DFBFE9 /* ComponentsView.swift */, - 18E3452B289D46C5005E6D81 /* FontsView.swift */, 1860058E28A136CE009C3778 /* ColorsView.swift */, + 18E3452B289D46C5005E6D81 /* FontsView.swift */, B8F98FFF2546C98600DFBFE9 /* Catalog */, B8F990062546C98600DFBFE9 /* Common */, ); @@ -490,6 +496,7 @@ 18E48584287F19EB0052A6F2 /* CarouselCatalogView.swift in Sources */, 18E485A4287F19EB0052A6F2 /* UICatalogCroutonViewController.swift in Sources */, 18E485A3287F19EB0052A6F2 /* UICatalogSectionTitleViewController.swift in Sources */, + 244D00C62C491D4600424AA5 /* SkeletonsCatalogView.swift in Sources */, 1860058F28A136CE009C3778 /* ColorsView.swift in Sources */, B8F9903D2546C98700DFBFE9 /* UITextFieldTableViewCell.swift in Sources */, 18E48587287F19EB0052A6F2 /* DataCardCatalogView.swift in Sources */, @@ -510,6 +517,7 @@ 18E4857F287F19EB0052A6F2 /* EmptyStateCatalogView.swift in Sources */, 18E4859F287F19EB0052A6F2 /* UICatalogPopoverViewController.swift in Sources */, B8F990392546C98700DFBFE9 /* Bundle+MisticaCatalog.swift in Sources */, + 244D00C82C49392700424AA5 /* UICatalogSkeletonsViewController.swift in Sources */, 18E17AED287FFFBF0051E505 /* CatalogRow.swift in Sources */, 18E48598287F19EB0052A6F2 /* UICatalogStepperViewController.swift in Sources */, 18E485A1287F19EB0052A6F2 /* UICatalogTabsViewController.swift in Sources */, diff --git a/MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Contents.json b/MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Contents.json new file mode 100644 index 000000000..9ab335435 --- /dev/null +++ b/MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "Skeleton.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Skeleton_Dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Skeleton.svg b/MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Skeleton.svg new file mode 100644 index 000000000..1b66d5713 --- /dev/null +++ b/MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Skeleton.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Skeleton_Dark.svg b/MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Skeleton_Dark.svg new file mode 100644 index 000000000..cc39fe2f5 --- /dev/null +++ b/MisticaCatalog/Source/Assets.xcassets/Rows/Skeleton.imageset/Skeleton_Dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/MisticaCatalog/Source/Catalog/CatalogList.swift b/MisticaCatalog/Source/Catalog/CatalogList.swift index 8ba0b68fb..375a090c5 100644 --- a/MisticaCatalog/Source/Catalog/CatalogList.swift +++ b/MisticaCatalog/Source/Catalog/CatalogList.swift @@ -65,6 +65,8 @@ private extension CatalogRow { BadgeCatalogView() case .buttons: ButtonsCatalogView() + case .skeletons: + SkeletonsCatalogView() case .cards: DataCardCatalogView() case .crouton: @@ -148,6 +150,8 @@ private extension CatalogRow { ComponentViewController(UICatalogEmptyStateViewController()) case .sheet: ComponentViewController(UICatalogSheetViewController()) + case .skeletons: + ComponentViewController(UICatalogSkeletonsViewController()) case .chips, .carousel: notImplementedView diff --git a/MisticaCatalog/Source/Catalog/CatalogRow.swift b/MisticaCatalog/Source/Catalog/CatalogRow.swift index c72e216c1..7b5ad6d7b 100644 --- a/MisticaCatalog/Source/Catalog/CatalogRow.swift +++ b/MisticaCatalog/Source/Catalog/CatalogRow.swift @@ -14,6 +14,7 @@ enum CatalogRow: Int, CaseIterable, Identifiable { case badge case sheet case buttons + case skeletons case cards case controls case crouton @@ -89,6 +90,8 @@ extension CatalogRow { return "Carousel" case .sheet: return "Sheet" + case .skeletons: + return "Skeletons" } } @@ -140,6 +143,8 @@ extension CatalogRow { return .listIcon case .sheet: return .sheetIcon + case .skeletons: + return .skeletonIcon } } } diff --git a/MisticaCatalog/Source/Catalog/Mistica/Components/UICatalogSkeletonsViewController.swift b/MisticaCatalog/Source/Catalog/Mistica/Components/UICatalogSkeletonsViewController.swift new file mode 100644 index 000000000..88fb7b441 --- /dev/null +++ b/MisticaCatalog/Source/Catalog/Mistica/Components/UICatalogSkeletonsViewController.swift @@ -0,0 +1,103 @@ +// +// UICatalogSkeletonsViewController.swift +// +// Made with ❤️ by Novum +// +// Copyright © Telefonica. All rights reserved. +// + +import Mistica +import UIKit + +class UICatalogSkeletonsViewController: UIViewController { + private let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + return scrollView + }() + + private let contentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + private func setupUI() { + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + + addSkeletons() + } + + private func addSkeletons() { + let mainStackView = UIStackView() + mainStackView.axis = .vertical + mainStackView.alignment = .fill + mainStackView.spacing = 40 + mainStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(mainStackView) + + NSLayoutConstraint.activate([ + mainStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + mainStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + mainStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + mainStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16) + ]) + + addSkeletonWithLabel(to: mainStackView, label: "Line", skeleton: Skeleton(type: .line(width: 360))) + + addSkeletonWithLabel(to: mainStackView, label: "Text", skeleton: Skeleton(type: .text())) + + addSkeletonWithLabel(to: mainStackView, label: "Circle", skeleton: Skeleton(type: .circle(size: 40))) + + addSkeletonWithLabel(to: mainStackView, label: "Row", skeleton: Skeleton(type: .row)) + + addSkeletonWithLabel(to: mainStackView, label: "Rectangle", skeleton: Skeleton(type: .rectangle(size: CGSize(width: 360, height: 180), isRounded: true))) + + contentView.heightAnchor.constraint(equalTo: mainStackView.heightAnchor, constant: 32).isActive = true + } + + private func addSkeletonWithLabel(to mainStackView: UIStackView, label: String, skeleton: Skeleton) { + let individualStackView = UIStackView() + individualStackView.axis = .vertical + individualStackView.alignment = .fill + individualStackView.spacing = 16 + individualStackView.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.text = label + titleLabel.font = UIFont.systemFont(ofSize: 16, weight: .bold) + + individualStackView.addArrangedSubview(titleLabel) + individualStackView.addArrangedSubview(skeleton) + + mainStackView.addArrangedSubview(individualStackView) + + individualStackView.widthAnchor.constraint(equalTo: mainStackView.widthAnchor).isActive = true + + if case .text = skeleton.type { + skeleton.widthAnchor.constraint(equalTo: individualStackView.widthAnchor).isActive = true + } + if case .row = skeleton.type { + skeleton.widthAnchor.constraint(equalTo: individualStackView.widthAnchor).isActive = true + } + } +} diff --git a/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/SkeletonsCatalogView.swift b/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/SkeletonsCatalogView.swift new file mode 100644 index 000000000..200739a6a --- /dev/null +++ b/MisticaCatalog/Source/Catalog/MisticaSwiftUI/Components/SkeletonsCatalogView.swift @@ -0,0 +1,54 @@ +// +// SkeletonsCatalogView.swift +// +// Made with ❤️ by Novum +// +// Copyright © Telefonica. All rights reserved. +// + +import Foundation +import MisticaSwiftUI +import SwiftUI + +struct SkeletonsCatalogView: View { + @State var inverseStyle = false + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 40.0) { + skeletonSection(title: "Line", skeleton: Skeleton(type: .line(width: 360))) + + skeletonSection(title: "Text", skeleton: Skeleton(type: .text())) + + skeletonSection(title: "Circle", skeleton: Skeleton(type: .circle(size: .init(width: 40, height: 40)))) + + skeletonSection(title: "Row", skeleton: Skeleton(type: .row)) + + skeletonSection(title: "Rectangle", skeleton: Skeleton(type: .rectangle(width: 360, height: 180, isRounded: true))) + + Spacer() + } + .padding() + .background(inverseStyle ? Color.brand : Color.background) + } + } + + private func skeletonSection(title: String, skeleton: Skeleton) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(title) + .font(.system(size: 16, weight: .bold)) + skeleton + } + } +} + +#if DEBUG + struct SkeletonCatalogView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + SkeletonsCatalogView() + } + .misticaNavigationViewStyle() + } + } +#endif diff --git a/MisticaCatalog/Source/Common/CatalogAssets.swift b/MisticaCatalog/Source/Common/CatalogAssets.swift index 16a404530..92296edd3 100644 --- a/MisticaCatalog/Source/Common/CatalogAssets.swift +++ b/MisticaCatalog/Source/Common/CatalogAssets.swift @@ -35,6 +35,7 @@ extension UIImage { static let calloutIcon = UIImage(named: "Callout", in: .misticaCatalog, compatibleWith: nil)! static let emptyStateIcon = UIImage(named: "EmptyStates", in: .misticaCatalog, compatibleWith: nil)! static let sheetIcon = UIImage(named: "Sheets", in: .misticaCatalog, compatibleWith: nil)! + static let skeletonIcon = UIImage(named: "Skeleton", in: .misticaCatalog, compatibleWith: nil)! static let highlightedCardImageSample = UIImage(named: "HighlightedCardImageSample", in: .misticaCatalog, compatibleWith: nil)! static let highlightedCardBackgroundImageSample = UIImage(named: "HighlightedCardBackgroundSample", in: .misticaCatalog, compatibleWith: nil)! diff --git a/Sources/Mistica/Components/Skeleton/SkeletonView.swift b/Sources/Mistica/Components/Skeleton/SkeletonView.swift new file mode 100644 index 000000000..6ef5609d0 --- /dev/null +++ b/Sources/Mistica/Components/Skeleton/SkeletonView.swift @@ -0,0 +1,174 @@ +// +// SkeletonView.swift +// +// Made with ❤️ by Novum +// +// Copyright © Telefonica. All rights reserved. +// + +import UIKit + +public enum SkeletonType { + case line(width: CGFloat) + case text(numberOfLines: Int = 3) + case circle(size: CGFloat) + case row + case rectangle(size: CGSize, isRounded: Bool) +} + +public class Skeleton: UIView { + enum Constants { + static let lineHeight: CGFloat = 8.0 + static let rectangleRadius: CGFloat = MisticaConfig.currentCornerRadius.container + static let spacing: CGFloat = 18.0 + static let circleSize: CGFloat = 40.0 + static let rowSkeletonHeight: CGFloat = 50.0 + + static var circleRadius: CGFloat { circleSize / 2 } + } + + public var type: SkeletonType + + public init(type: SkeletonType) { + self.type = type + super.init(frame: .zero) + setupView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + switch type { + case .line(let width): + let skeletonView = createSkeletonView(size: CGSize(width: width, height: Constants.lineHeight), cornerRadius: Constants.lineHeight / 2) + addSubview(skeletonView) + skeletonView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + skeletonView.topAnchor.constraint(equalTo: topAnchor), + skeletonView.leadingAnchor.constraint(equalTo: leadingAnchor), + skeletonView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + skeletonView.bottomAnchor.constraint(equalTo: bottomAnchor), + skeletonView.heightAnchor.constraint(equalToConstant: Constants.lineHeight) + ]) + case .text(let numberOfLines): + setupTextSkeleton(numberOfLines: numberOfLines) + case .circle(let size): + let skeletonView = createSkeletonView(size: CGSize(width: size, height: size), cornerRadius: size / 2) + addSubview(skeletonView) + skeletonView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + skeletonView.topAnchor.constraint(equalTo: topAnchor), + skeletonView.leadingAnchor.constraint(equalTo: leadingAnchor), + skeletonView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + skeletonView.bottomAnchor.constraint(equalTo: bottomAnchor), + skeletonView.widthAnchor.constraint(equalToConstant: size), + skeletonView.heightAnchor.constraint(equalToConstant: size) + ]) + case .row: + setupRowSkeleton() + case .rectangle(let size, let isRounded): + let skeletonView = createSkeletonView(size: size, cornerRadius: isRounded ? Constants.rectangleRadius : 0) + addSubview(skeletonView) + skeletonView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + skeletonView.topAnchor.constraint(equalTo: topAnchor), + skeletonView.leadingAnchor.constraint(equalTo: leadingAnchor), + skeletonView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + skeletonView.bottomAnchor.constraint(equalTo: bottomAnchor), + skeletonView.widthAnchor.constraint(equalToConstant: size.width), + skeletonView.heightAnchor.constraint(equalToConstant: size.height) + ]) + } + } + + private func createSkeletonView(size: CGSize, cornerRadius: CGFloat) -> UIView { + let view = UIView() + view.backgroundColor = .backgroundSkeleton + view.layer.cornerRadius = cornerRadius + view.layer.masksToBounds = true + view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: size.width), + view.heightAnchor.constraint(equalToConstant: size.height) + ]) + + addPulseAnimation(to: view) + return view + } + + private func setupTextSkeleton(numberOfLines: Int) { + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .leading + stack.distribution = .fillEqually + stack.spacing = Constants.spacing + stack.translatesAutoresizingMaskIntoConstraints = false + + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + for i in 0 ..< numberOfLines { + let isLastLine = i == numberOfLines - 1 + let line = createSkeletonView(size: CGSize(width: bounds.width, height: Constants.lineHeight), cornerRadius: Constants.lineHeight / 2) + line.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(line) + + if isLastLine { + line.widthAnchor.constraint(equalTo: stack.widthAnchor, multiplier: 0.8).isActive = true + } else { + line.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + } + } + } + + private func setupRowSkeleton() { + let stack = UIStackView() + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = Constants.spacing + stack.translatesAutoresizingMaskIntoConstraints = false + + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + heightAnchor.constraint(equalToConstant: Constants.rowSkeletonHeight) + ]) + + let circle = createSkeletonView(size: CGSize(width: Constants.circleSize, height: Constants.circleSize), cornerRadius: Constants.circleRadius) + let rectangle = createSkeletonView(size: CGSize(width: bounds.width - Constants.circleSize - Constants.spacing, height: Constants.lineHeight), cornerRadius: Constants.lineHeight / 2) + + stack.addArrangedSubview(circle) + stack.addArrangedSubview(rectangle) + + circle.translatesAutoresizingMaskIntoConstraints = false + rectangle.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + circle.widthAnchor.constraint(equalToConstant: Constants.circleSize), + circle.heightAnchor.constraint(equalToConstant: Constants.circleSize), + rectangle.heightAnchor.constraint(equalToConstant: Constants.lineHeight) + ]) + } + + private func addPulseAnimation(to view: UIView) { + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = 1 + animation.toValue = 0.5 + animation.duration = 1.5 + animation.autoreverses = true + animation.repeatCount = .infinity + view.layer.add(animation, forKey: "pulseAnimation") + } +} diff --git a/Sources/MisticaSwiftUI/Components/Skeletons/Skeleton.swift b/Sources/MisticaSwiftUI/Components/Skeletons/Skeleton.swift new file mode 100644 index 000000000..633113240 --- /dev/null +++ b/Sources/MisticaSwiftUI/Components/Skeletons/Skeleton.swift @@ -0,0 +1,131 @@ +// +// Skeleton.swift +// +// Made with ❤️ by Novum +// +// Copyright © Telefonica. All rights reserved. +// + +import SwiftUI + +public enum SkeletonType { + case line(width: CGFloat) + case text(numberOfLines: Int = 3) + case circle(size: CGSize) + case row + case rectangle(width: CGFloat, height: CGFloat, isRounded: Bool) +} + +public struct Skeleton: View { + enum Constants { + static var lineHeight = 8.0 + static var radius = MisticaConfig.currentCornerRadius.container + static var spacing = 16.0 + static var circleSize = 40.0 + } + + let type: SkeletonType + + public init(type: SkeletonType) { + self.type = type + } + + public var body: some View { + switch type { + case .line(let width): + return AnyView( + skeletonRectangle(width: width) + ) + + case .text(let numberOfLines): + return AnyView( + VStack(alignment: .leading, spacing: Constants.spacing) { + ForEach(0 ..< numberOfLines, id: \.self) { index in + GeometryReader { geometry in + if index == numberOfLines - 1 { + skeletonRectangle(width: geometry.size.width * 0.8) + } else { + skeletonRectangle() + } + } + } + } + ) + + case .circle(let size): + return AnyView( + skeletonCircle(size: size) + ) + + case .row: + return AnyView( + HStack(alignment: .center, spacing: Constants.spacing) { + skeletonCircle(size: .init(width: Constants.circleSize, height: Constants.circleSize)) + skeletonRectangle() + } + ) + + case .rectangle(let width, let height, let isRounded): + return AnyView( + skeletonRectangle(width: width, height: height, isRounded: isRounded) + ) + } + } + + private func skeletonRectangle(width: CGFloat? = nil, + height: CGFloat = Constants.lineHeight, + isRounded: Bool = true) -> some View { + Rectangle() + .frame(width: width, height: height) + .foregroundColor(.backgroundSkeleton) + .cornerRadius(isRounded ? Constants.radius : .zero) + .modifier(PulseAnimation()) + } + + private func skeletonCircle(size: CGSize) -> some View { + Circle() + .frame(width: size.width, height: size.height) + .foregroundColor(.backgroundSkeleton) + .modifier(PulseAnimation()) + } +} + +// MARK: Helpers + +struct PulseAnimation: ViewModifier { + @State private var isAnimating = false + + func body(content: Content) -> some View { + content + .opacity(isAnimating ? 1 : 0.5) + .animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: isAnimating) + .onAppear { + DispatchQueue.main.async { + isAnimating = true + } + } + } +} + +// MARK: Previews + +#if DEBUG + struct Skeleton_Previews: PreviewProvider { + static var previews: some View { + VStack { + styledPreviews() + .expandHorizontally() + } + } + + private static func styledPreviews() -> some View { + VStack(alignment: .leading, spacing: 40) { + Skeleton(type: .line(width: 360)) + Skeleton(type: .text()) + Skeleton(type: .circle(size: .init(width: 40, height: 40))) + Skeleton(type: .row) + Skeleton(type: .rectangle(width: 360, height: 180, isRounded: true)) + }.padding() + } + } +#endif diff --git a/Tests/MisticaSwiftUITests/UI/SkeletonTests.swift b/Tests/MisticaSwiftUITests/UI/SkeletonTests.swift new file mode 100644 index 000000000..0bc89da8c --- /dev/null +++ b/Tests/MisticaSwiftUITests/UI/SkeletonTests.swift @@ -0,0 +1,75 @@ +// +// SkeletonTests.swift +// +// Made with ❤️ by Novum +// +// Copyright © Telefonica. All rights reserved. +// + +@testable import MisticaSwiftUI +import SnapshotTesting +import SwiftUI +import XCTest + +final class SkeletonTests: XCTestCase { + override class func setUp() { + isRecording = false + } + + func testLineSkeleton() { + let skeleton = Skeleton(type: .line(width: 300)) + + assertSnapshot( + matching: skeleton, + as: .image + ) + } + + func testTextSkeleton() { + let skeleton = Skeleton(type: .text()) + .frame(width: 300, height: 60) + + assertSnapshot( + matching: skeleton, + as: .image + ) + } + + func testTextSkeletonWithCustomLines() { + let skeleton = Skeleton(type: .text(numberOfLines: 5)) + .frame(width: 300, height: 110) + + assertSnapshot( + matching: skeleton, + as: .image + ) + } + + func testCircleSkeleton() { + let skeleton = Skeleton(type: .circle(size: CGSize(width: 40, height: 40))) + + assertSnapshot( + matching: skeleton, + as: .image + ) + } + + func testRowSkeleton() { + let skeleton = Skeleton(type: .row) + .frame(width: 300) + + assertSnapshot( + matching: skeleton, + as: .image + ) + } + + func testRectangleSkeleton() { + let skeleton = Skeleton(type: .rectangle(width: 360, height: 180, isRounded: true)) + + assertSnapshot( + matching: skeleton, + as: .image + ) + } +} diff --git a/Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testCircleSkeleton.1.png b/Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testCircleSkeleton.1.png new file mode 100644 index 0000000000000000000000000000000000000000..78c7bbde55e14319561d9bdbbbf46734b14ac8b4 GIT binary patch literal 1842 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 literal 0 HcmV?d00001 diff --git a/Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testTextSkeletonWithCustomLines.1.png b/Tests/MisticaSwiftUITests/UI/__Snapshots__/SkeletonTests/testTextSkeletonWithCustomLines.1.png new file mode 100644 index 0000000000000000000000000000000000000000..1e5cc534759ab6dd29dde0b91167dfa2050636b0 GIT binary patch literal 8668 zcmeHMeLR%e9-r}Oj53;P5#wd5E!##ThHN7x*3pP1$dw3SRs{S*Ahi%nKGqS4eMsD=`XS_3b217D#5o^?;+)Jeu$=t}!3Wfy zD*+C2p+}Cwyfnb%0P}U^zFD=jf@gZ5K!d-IGzbS$y{jk$?YKgs8*P(z0bZw9M&;02Lw!iJ?*et>-bQ z+3K}cp3pm-9O`K1RN`bN3Shl?NUn4FT`4fHSLf@<5A$LlHFzYg+7g-1YPu%V;gZ8u zN!SU!ojjOC45~f=k0UrrI{CR)VcbrP6(R1$UxwywKEKDHRkrfkR%yRc%h=qPnGBP; zq0C8qpozy^mX=zjwWdU*=e_-qRL^fo{W#V+fgUQpJs`@q%M2|?q<^DYqjCNeyoY2e z9%sZ_FBm$nKdN2b0=I8u3$(>1N_4LEQlf&)mqC3&73ZOfmS_+()LzY+Y1Is)25xiz_%ze_xMn_$-4 zWdUjS@^i&8osGY)YpWieq#pXTEZ|gSTMH{ZnwMW`(>qp|WC~q1Mw^z9o|oi8TJzu4 z#zRGs_N84peA*eaRZvh)I| z*NH|pKpVX+CY%;$G}9HSykwgV$8x!$FVsg|RFS-H?B#@p1JQI8@^Ir&0N1hW5uk%oFs=GVf-Pgy|=jcD4hTM(cr39_l}fV>axZ{9~QLsheXOICXVvk-vm2UY=PqoG0+)sXTOmP zPqqcN1-G5%Zm-l~PTEW&RL9D9dNw#i!vY#XyU!t=0@Fx7K356h4cn>Qu9zf4s4^7=+X4Xz2tStuB zvMn>SoHg+}SRCa~$}P7jG)&o^IqcNsu{kH}%H z)2dZ>nmTQNS7*g9z0cF3vAV|>@Aw=~lP=SbzWZ$KNsq;~a|_Kp8ip44+-7(DH;!qR zdIw+#G=nNhO_F9C%XTKF-(64vJz?Ysv@gFh?5EHC?`kfjE{!J1azn2g*xYB0fnX}1 z>GHh(>YCv}HfsYEiwj)<5E2b0*#_>C$T-`C_*;HTe-P6 z+ZyVlfmTXMXV{b`bCUdiT(*=>4lAKSEpQknxZ0k`=qN4bWL&;AK~L>n`lSeeIrMTO zt^hYs#k4@YI^G54HjTfdKNjcJ5s8THfdV`Tzu+}RG?7)cMUgKHQ0kg^G<74tHY#RH zT~_YtkX&lwUvo;j7P@UfzYUc#?l%03K2SotPb*_5h=oz$K?%Av4#hb9b6XyM;7rc@ z^YiF93wG;~afOdxubl_hS)3eQWrUVx-XyJEz|k1WULM|HOBqa6uBcK?_wGyn3zGjr zSi6-*Xq)|;VX9=%L}&Vgbx<3ZLEV)piBQ$W;o%p(x48Gv|I^W!2*Fyi8^(si@=M!K zRyf~jZ|C#*+d4L<3i^Ixz(OsDF`~6vW+37F3laoZ1v#?8;X(XjQu+5tDlZyN=z;vg z%#2mb^g)nZpv{sCgQ7(LSGDR1_S_jDE`G)S0GlftQW8s!In_zxFQ=rZ^d0nlq4a3O zm-};@-bI*emw$dCD!h*JRlio>TfDQgF&j6%eFB~y9|la99ZVHGp-}P7kV^?zEi9f@ zynS|amRa8l1=dO^s(~r6566r5ARll$kihS5T&F;a+{II)laGXX59UKKayMWqd!aJ1 z1=~WNaM-~}qLjDBLN{bEF0U=XsYQZKKI?Gx$r;q)Cw#;`xv#5|#z0JueSvTXY_o(&LIZdIvwg7y}UX{K$Zdj+b*cFRkF zt_fFpcE;L%LRxQl^sT4CgW0=6A5l3cT)TyrF9;^6SR?Ddy_o zwvdz&sSvJ!sxCMA4Tg316jhjp8(>&>QQs9`NBRy9u{J!4@I(;|Vi&tx^xfh{`8%Hq zaVujexXh`ccVz7Jw&T6w&Yj8p?vQdF)pNJD!qdMPlyfUDt)Wkz*6y2d%i*@ygU-#J zs?4!-eT2ZgJEX%O2B7hV!-yo>6^m@+x>6a1Ikg!ElHm(-9R)a7NF0))d~0qwNM=%P zfvzG0l$Gu0OfL3M%Co4_R2JBYb&9NC|BSa7&Mq_dm<7r*t3&Yly(tkHd2e+!Gr5i~ z(DX_;8@hjDZ*)@H!KHZNl)9I`(uOCA-b6bS*W)=ED(qq9XjL~q2;B%)g$HMmg&rD6 zQhN3K=lP|#sa>)7^}AnVBYz0AWVJy1EzF#SoxUprI=_diK5_V*2G+zh8<K}#AGut3VODkZ6;72dpyZIaLcY$Y$LldZ$+$8A@ zW7au!6L%=GP+k2n`P{=bf`jN&es0nTI4jHFT39*2)W|t&aW731K7B_&*Jw9sKRYq^ zryo!G1^tJn=||T|*%>MoQ;)j~lf@mM7QWeJNii0rC z16w!i0d8_VL6K7xSvAWLhKf}26-;y8naD{ZwnfSJ*yyPJrAp&NY{#?ERlXkpn0AZk z-hnD$dRi@|Y-cwUGRh=KbVe=|rinv8qI^qmWD|2#)$nvXBQUCP+U~%mF^%S1!dAxv zSGxz*ei{De_yy=ff00MKMz1?*`sjthcoW{-tI|cO`fhC`G%3W^t}?>*WZs0+ ztLt6yv@H%Wdes66xOJre38Q9@fyk?#0%LU-yIb_#;zs$qoC*eb;014jx3e2so8Fzy z1h3j8T~_OGsJZs-j@6LxTTPtz*&#!vNI28hNBWW3FaAJz^HQjQ*peXygvU9G*f zpS&9qy2h!T_sei9bVpTZ6S`bMY+ z=Cm1S0(eckn`+j`(tE-`TyFfuWz*FAt%oY^PBK9SeE^I_HR_p5@F>le(wEJcz@)WCge6RXjFae|`N zOMhw5JF_mb6X%d+F~9giUm!%UEuQjEaO)JBIk4zq(mdSjf)2dw!M%C@mRAhA?{TF` z+BQ^MD|W&dMBBc}MtWlsuH9QClv*5Xn#-%fyF_!e8;GI1QIeD;l{#pCLjOKH){o6< zC$f6S3UoJW1~@)^C5@Qk@A~83YWB&~x{Olh9 literal 0 HcmV?d00001 diff --git a/Tests/MisticaTests/UI/SkeletonTests.swift b/Tests/MisticaTests/UI/SkeletonTests.swift new file mode 100644 index 000000000..e6298aead --- /dev/null +++ b/Tests/MisticaTests/UI/SkeletonTests.swift @@ -0,0 +1,87 @@ +// +// SkeletonTests.swift +// +// Made with ❤️ by Novum +// +// Copyright © Telefonica. All rights reserved. +// + +@testable import Mistica +import SnapshotTesting +import XCTest + +final class SkeletonTests: XCTestCase { + override class func setUp() { + super.setUp() + + isRecording = false + } + + func testLineSkeleton() { + assertSnapshot( + matching: makeSkeletonTemplate(type: .line(width: 300)), + as: .image + ) + } + + func testTextSkeleton() { + assertSnapshot( + matching: makeSkeletonTemplate(type: .text()), + as: .image + ) + } + + func testTextSkeletonWithCustomLines() { + assertSnapshot( + matching: makeSkeletonTemplate(type: .text(numberOfLines: 5)), + as: .image + ) + } + + func testCircleSkeleton() { + assertSnapshot( + matching: makeSkeletonTemplate(type: .circle(size: 40), containerWidth: 40), + as: .image + ) + } + + func testRowSkeleton() { + assertSnapshot( + matching: makeSkeletonTemplate(type: .row), + as: .image + ) + } + + func testRectangleSkeleton() { + assertSnapshot( + matching: makeSkeletonTemplate(type: .rectangle(size: CGSize(width: 360, height: 180), isRounded: true), containerWidth: 360), + as: .image + ) + } +} + +private extension SkeletonTests { + func makeSkeletonTemplate(type: SkeletonType, containerWidth: CGFloat = 300) -> UIView { + let containerView = UIView() + containerView.backgroundColor = .white + + let skeleton = Skeleton(type: type) + containerView.addSubview(skeleton) + + skeleton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + skeleton.topAnchor.constraint(equalTo: containerView.topAnchor), + skeleton.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + skeleton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + skeleton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + skeleton.widthAnchor.constraint(equalToConstant: containerWidth) + ]) + + containerView.layoutIfNeeded() + + let size = skeleton.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + containerView.frame.size = CGSize(width: size.width, height: size.height) + + return containerView + } +} diff --git a/Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testCircleSkeleton.1.png b/Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testCircleSkeleton.1.png new file mode 100644 index 0000000000000000000000000000000000000000..8f0985b32c1a24356175e987e2e9916a1feaae18 GIT binary patch literal 2204 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 Z_5fj{fQ+%*0sFrWz|z#(glvqx`G2DV%uWCR literal 0 HcmV?d00001 diff --git a/Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testLineSkeleton.1.png b/Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testLineSkeleton.1.png new file mode 100644 index 0000000000000000000000000000000000000000..6ecb47999152f0c58aad4ce62ef8c2155e98a2e2 GIT binary patch literal 1128 zcmeAS@N?(olHy`uVBq!ia0y~yU~U1jB{ILO_JVcj{Imq1QSYJ_K+ zuP=iZkj=rs$|%IZ3{nDw42)6?tY9_+gBPPToE^ld0ae4qz|fw_zyehh1*AdX1`q>v zLuuv(j0h7KFu_zaw=7_Wu^A*l+G_p3zX4L51s;*b3=E1EAk64G%`gEdD3j^p9|EL- z7J>l}kkkNT5DmnP=jRLR8v^Oso-U3d6^w809`tewlxV&9`Cq>JqJ#%NOFI+{xOLx6 z)D@YgW+D8)biqW^!u3t>SKYm)KPOS**X^|1;r;9XCjPr}{Bo+iuh>2tb{>NR49qa% z0I!tC=B0Np6=d!-xx4i8IolcXRdtclKiJsCp6Gd?s5E7}RQA91p{Kw*yZygXwh1*S zO<-h`NN9i&JA|1t_iVX$|Fbs7efu)D{gV%_-hh+P`>b5?nCm-w~Y}l4LI&_>-}%z)^>$2{3)+`-izP8 zhNRbE0^6BPO=pD$gmXZwEJ+XUZFgoL`mnWa`}Vz(NRkS=H;N8BNhBZ%!POtVhgJQt z%S#nX-a8_DIbp$!#w`!z7#RNl-*vj!9~e#RK+*K}?p-gYM3J_O@Bh8mb2)fm^Asnh zgLM~@u7ux6j1V{YyteUzgjMGHmh^th+-(u_<(~QP`@@G6k_ioseLPE8Z$Ex@^n$h2 zt{>a~AUm^B0_dr;pZx^5oKW3j%$#}f+qXJ{ALrQel~a_DAYz#TC}YOctRT{Y9A*a` z(%CLGy)Ciaw!ZrC`@?WwQcxOD8YSBFWvq+iCRS}+MK;{}0GH32LdEhVb z?BvJSw;!n6RY)(WGj~YK|AsIjRpD*;@vmHowVyv$inK05O)WE408_%-kJ=o?N?TvN zUA&EbtL=vqOh|S-HlJ{8_gh=0Z571ppH3n+m>i4J>Bo|9ATg5GVY6- U8qb;FPyxz%p00i_>zopr06-FUga7~l literal 0 HcmV?d00001 diff --git a/Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testRectangleSkeleton.1.png b/Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testRectangleSkeleton.1.png new file mode 100644 index 0000000000000000000000000000000000000000..6459fdbefaa35513d48a9380fd09fafe48a99bdf GIT binary patch literal 14952 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 literal 0 HcmV?d00001 diff --git a/Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testRowSkeleton.1.png b/Tests/MisticaTests/UI/__Snapshots__/SkeletonTests/testRowSkeleton.1.png new file mode 100644 index 0000000000000000000000000000000000000000..e5d649f29149c5e1bd0f71a3fa799257fb5ce415 GIT binary patch literal 5286 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 literal 0 HcmV?d00001