diff --git a/Sources/GravatarUI/DesignSystem/Palette.swift b/Sources/GravatarUI/DesignSystem/Palette.swift index 6fa9fc27..6a53d343 100644 --- a/Sources/GravatarUI/DesignSystem/Palette.swift +++ b/Sources/GravatarUI/DesignSystem/Palette.swift @@ -16,6 +16,12 @@ public struct Palette { public let foreground: ForegroundColors public let background: BackgroundColors public let avatarBorder: UIColor + public let placeholder: PlaceholderColors +} + +public struct PlaceholderColors { + var backgroundColor: UIColor + var loadingAnimationColors: [UIColor] } public enum PaletteType { @@ -64,7 +70,14 @@ extension Palette { light: light.background.primary, dark: dark.background.primary )), - avatarBorder: .porpoiseGray + avatarBorder: .porpoiseGray, + placeholder: PlaceholderColors( + backgroundColor: UIColor( + light: light.placeholder.backgroundColor, + dark: dark.placeholder.backgroundColor + ), + loadingAnimationColors: systemPlaceholderAnimationColors() + ) ) } @@ -77,7 +90,11 @@ extension Palette { secondary: .dugongGray ), background: .init(primary: .white), - avatarBorder: .porpoiseGray + avatarBorder: .porpoiseGray, + placeholder: PlaceholderColors( + backgroundColor: .smokeWhite, + loadingAnimationColors: [.smokeWhite, .bleachedSilkWhite] + ) ) } @@ -90,7 +107,23 @@ extension Palette { secondary: .snowflakeWhite60 ), background: .init(primary: .gravatarBlack), - avatarBorder: .porpoiseGray + avatarBorder: .porpoiseGray, + placeholder: PlaceholderColors( + backgroundColor: .boatAnchorGray, + loadingAnimationColors: [.boatAnchorGray, .spanishGray] + ) ) } + + private static func systemPlaceholderAnimationColors() -> [UIColor] { + var colors: [UIColor] = [] + let count = min(light.placeholder.loadingAnimationColors.count, dark.placeholder.loadingAnimationColors.count) + for i in 0 ..< count { + colors.append(UIColor( + light: light.placeholder.loadingAnimationColors[i], + dark: dark.placeholder.loadingAnimationColors[i] + )) + } + return colors + } } diff --git a/Sources/GravatarUI/DesignSystem/UIColor+DesignSystem.swift b/Sources/GravatarUI/DesignSystem/UIColor+DesignSystem.swift index e90219b1..20f4a34b 100644 --- a/Sources/GravatarUI/DesignSystem/UIColor+DesignSystem.swift +++ b/Sources/GravatarUI/DesignSystem/UIColor+DesignSystem.swift @@ -18,7 +18,11 @@ extension UIColor { static let gravatarBlack: UIColor = .rgba(16, 21, 23) static let snowflakeWhite: UIColor = .rgba(240, 240, 240) static let porpoiseGray: UIColor = .rgba(218, 218, 218) + static let bleachedSilkWhite: UIColor = .rgba(242, 242, 242) + static let smokeWhite: UIColor = .rgba(229, 231, 233) static let snowflakeWhite60: UIColor = snowflakeWhite.withAlphaComponent(0.6) + static let boatAnchorGray: UIColor = .rgba(107, 107, 107) + static let spanishGray: UIColor = .rgba(151, 151, 151) static let dugongGray: UIColor = .rgba(112, 112, 112) static func rgba(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, alpha: CGFloat = 1.0) -> UIColor { diff --git a/Sources/GravatarUI/ProfileView/BaseProfileView.swift b/Sources/GravatarUI/ProfileView/BaseProfileView.swift index 2e034224..f676d453 100644 --- a/Sources/GravatarUI/ProfileView/BaseProfileView.swift +++ b/Sources/GravatarUI/ProfileView/BaseProfileView.swift @@ -2,16 +2,27 @@ import Gravatar import UIKit open class BaseProfileView: UIView, UIContentView { - private enum Constants { + enum Constants { static let avatarLength: CGFloat = 72 static let maximumAccountsDisplay = 4 static let accountIconLength: CGFloat = 32 + static let defaultDisplayNamePlaceholderHeight: CGFloat = 24 } open var avatarLength: CGFloat { Constants.avatarLength } + public enum PlaceholderColorPolicy { + /// Gets the placeholder colors from the current palette. + case currentPalette + /// Custom colors. You can also pass predefined colors from any ``Palette``. Example: `Palette.light.placeholder`. + case custom(PlaceholderColors) + } + + /// Placeholder color policy to use in the placeholder state (which basically means when all fields are empty). + public var placeholderColorPolicy: PlaceholderColorPolicy = .currentPalette + public var profileButtonStyle: ProfileButtonStyle = .view { didSet { Configure(profileButton).asProfileButton().style(profileButtonStyle) @@ -71,6 +82,7 @@ open class BaseProfileView: UIView, UIContentView { imageView.heightAnchor.constraint(equalToConstant: avatarLength).isActive = true imageView.layer.cornerRadius = avatarLength / 2 imageView.clipsToBounds = true + imageView.setContentHuggingPriority(.defaultHigh, for: .horizontal) return imageView }() @@ -81,6 +93,16 @@ open class BaseProfileView: UIView, UIContentView { return label }() + /// The placeholder state of "about me" label consists of 2 separate lines in some designs. This label's only purpose is to serve as the 2nd line of that + /// placeholder. + lazy var aboutMePlaceholderLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.isHidden = true + return label + }() + public private(set) lazy var displayNameLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false @@ -109,6 +131,7 @@ open class BaseProfileView: UIView, UIContentView { stack.translatesAutoresizingMaskIntoConstraints = false stack.axis = .horizontal stack.spacing = .DS.Padding.half + stack.setContentHuggingPriority(.defaultHigh, for: .horizontal) return stack }() @@ -247,6 +270,21 @@ open class BaseProfileView: UIView, UIContentView { profileURL: url ) } + + // MARK: - Placeholder handling + + var placeholderColors: PlaceholderColors { + switch placeholderColorPolicy { + case .currentPalette: + paletteType.palette.placeholder + case .custom(let placeholderColors): + placeholderColors + } + } + + open var displayNamePlaceholderHeight: CGFloat { + Constants.defaultDisplayNamePlaceholderHeight + } } public protocol ProfileViewDelegate: NSObjectProtocol { diff --git a/Sources/GravatarUI/ProfileView/LargeProfileSummaryView.swift b/Sources/GravatarUI/ProfileView/LargeProfileSummaryView.swift index db4c5030..9c140444 100644 --- a/Sources/GravatarUI/ProfileView/LargeProfileSummaryView.swift +++ b/Sources/GravatarUI/ProfileView/LargeProfileSummaryView.swift @@ -4,6 +4,7 @@ import UIKit public class LargeProfileSummaryView: BaseProfileView { private enum Constants { static let avatarLength: CGFloat = 132.0 + static let displayNamePlaceholderHeight: CGFloat = 32 } public static var personalInfoLines: [PersonalInfoLine] { @@ -44,4 +45,8 @@ public class LargeProfileSummaryView: BaseProfileView { guard let model = config.summaryModel else { return } update(with: model) } + + override public var displayNamePlaceholderHeight: CGFloat { + Constants.displayNamePlaceholderHeight + } } diff --git a/Sources/GravatarUI/ProfileView/LargeProfileView.swift b/Sources/GravatarUI/ProfileView/LargeProfileView.swift index 395452c3..77c49d90 100644 --- a/Sources/GravatarUI/ProfileView/LargeProfileView.swift +++ b/Sources/GravatarUI/ProfileView/LargeProfileView.swift @@ -4,6 +4,7 @@ import UIKit public class LargeProfileView: BaseProfileView { private enum Constants { static let avatarLength: CGFloat = 132.0 + static let displayNamePlaceholderHeight: CGFloat = 32 } override public var avatarLength: CGFloat { @@ -57,4 +58,8 @@ public class LargeProfileView: BaseProfileView { guard let model = config.model else { return } update(with: model) } + + override public var displayNamePlaceholderHeight: CGFloat { + Constants.displayNamePlaceholderHeight + } } diff --git a/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/AccountButtonsPlaceholderDisplayer.swift b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/AccountButtonsPlaceholderDisplayer.swift new file mode 100644 index 00000000..0c27b1b8 --- /dev/null +++ b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/AccountButtonsPlaceholderDisplayer.swift @@ -0,0 +1,63 @@ +import UIKit + +/// This ``PlaceholderDisplaying`` implementation is tailored for account buttons. It shows 4 shadow account buttons in the given color. +@MainActor +class AccountButtonsPlaceholderDisplayer: PlaceholderDisplaying { + var placeholderColor: UIColor + private let containerStackView: UIStackView + let isTemporary: Bool + init(containerStackView: UIStackView, color: UIColor, isTemporary: Bool = false) { + self.placeholderColor = color + self.isTemporary = isTemporary + self.containerStackView = containerStackView + } + + func showPlaceholder() { + removeAllArrangedSubviews() + [placeholderView(), placeholderView(), placeholderView(), placeholderView()].forEach(containerStackView.addArrangedSubview) + if isTemporary { + containerStackView.isHidden = false + } + } + + func hidePlaceholder() { + removeAllArrangedSubviews() + if isTemporary { + containerStackView.isHidden = true + } + } + + private func removeAllArrangedSubviews() { + for view in containerStackView.arrangedSubviews { + containerStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + } + + private func placeholderView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = placeholderColor + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: BaseProfileView.Constants.accountIconLength), + view.widthAnchor.constraint(equalToConstant: BaseProfileView.Constants.accountIconLength), + ]) + view.layer.cornerRadius = BaseProfileView.Constants.accountIconLength / 2 + view.clipsToBounds = true + return view + } + + func set(viewColor newColor: UIColor?) { + for arrangedSubview in containerStackView.arrangedSubviews { + arrangedSubview.backgroundColor = newColor + } + } + + func set(layerColor newColor: UIColor?) { + for arrangedSubview in containerStackView.arrangedSubviews { + arrangedSubview.layer.backgroundColor = newColor?.cgColor + } + } + + func prepareForAnimation() {} +} diff --git a/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/BackgroundColorPlaceholderDisplayer.swift b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/BackgroundColorPlaceholderDisplayer.swift new file mode 100644 index 00000000..c78758db --- /dev/null +++ b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/BackgroundColorPlaceholderDisplayer.swift @@ -0,0 +1,45 @@ +import UIKit + +/// This ``PlaceholderDisplaying`` implementation updates the background color when `showPlaceholder()` is called. +@MainActor +class BackgroundColorPlaceholderDisplayer: PlaceholderDisplaying { + var placeholderColor: UIColor + let baseView: T + let isTemporary: Bool + let originalBackgroundColor: UIColor + + init(baseView: T, color: UIColor, originalBackgroundColor: UIColor, isTemporary: Bool = false) { + self.placeholderColor = color + self.baseView = baseView + self.isTemporary = isTemporary + self.originalBackgroundColor = originalBackgroundColor + } + + func showPlaceholder() { + if isTemporary { + baseView.isHidden = false + } + set(viewColor: placeholderColor) + } + + func hidePlaceholder() { + set(layerColor: .clear) + set(viewColor: originalBackgroundColor) + if isTemporary { + baseView.isHidden = true + } + } + + func set(viewColor newColor: UIColor?) { + // UIColor can automatically adjust according to `UIUserInterfaceStyle`, but CGColor can't. + // That's why we can't just rely on `layer.backgroundColor`. We need to set this. + baseView.backgroundColor = newColor + } + + func set(layerColor newColor: UIColor?) { + // backgroundColor is not animatable for some UIView subclasses. For example: UILabel. So we need to animate over `layer.backgroundColor`. + baseView.layer.backgroundColor = newColor?.cgColor + } + + func prepareForAnimation() {} +} diff --git a/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/LabelPlaceholderDisplayer.swift b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/LabelPlaceholderDisplayer.swift new file mode 100644 index 00000000..13d247b5 --- /dev/null +++ b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/LabelPlaceholderDisplayer.swift @@ -0,0 +1,10 @@ +import UIKit + +/// A ``PlaceholderDisplaying`` implementation for a UILabel. +@MainActor +class LabelPlaceholderDisplayer: RectangularPlaceholderDisplayer { + override func prepareForAnimation() { + // If UILabel's backgroundColor is set, the animation won't be visible. So we need to clear it. This is only needed for UILabel so far. + set(viewColor: .clear) + } +} diff --git a/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/ProfileButtonPlaceholderDisplayer.swift b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/ProfileButtonPlaceholderDisplayer.swift new file mode 100644 index 00000000..5e4bcf72 --- /dev/null +++ b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/ProfileButtonPlaceholderDisplayer.swift @@ -0,0 +1,17 @@ +import UIKit + +/// A ``PlaceholderDisplaying`` implementation for the "Edit/View profile" button. +@MainActor +class ProfileButtonPlaceholderDisplayer: RectangularPlaceholderDisplayer { + override func showPlaceholder() { + super.showPlaceholder() + baseView.imageView?.isHidden = true + baseView.titleLabel?.isHidden = true + } + + override func hidePlaceholder() { + super.hidePlaceholder() + baseView.imageView?.isHidden = false + baseView.titleLabel?.isHidden = false + } +} diff --git a/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/RectangularPlaceholderDisplayer.swift b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/RectangularPlaceholderDisplayer.swift new file mode 100644 index 00000000..ef1c213c --- /dev/null +++ b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/RectangularPlaceholderDisplayer.swift @@ -0,0 +1,48 @@ +import UIKit + +/// A ``PlaceholderDisplaying`` implementation that Inherits ``BackgroundColorPlaceholderDisplayer`. +/// In addition to ``BackgroundColorPlaceholderDisplayer``, this gives a size to the ui element and rounds its corners a bit when `showPlaceholder()` is +/// called. +@MainActor +class RectangularPlaceholderDisplayer: BackgroundColorPlaceholderDisplayer { + private let cornerRadius: CGFloat + private let height: CGFloat + private let widthRatioToParent: CGFloat + private var layoutConstraints: [NSLayoutConstraint] = [] + private var isShowing: Bool = false + private var originalCornerRadius: CGFloat + + init( + baseView: T, + color: UIColor, + originalBackgroundColor: UIColor = .clear, + cornerRadius: CGFloat, + height: CGFloat, + widthRatioToParent: CGFloat, + isTemporary: Bool = false + ) { + self.cornerRadius = cornerRadius + self.height = height + self.widthRatioToParent = widthRatioToParent + self.originalCornerRadius = baseView.layer.cornerRadius + super.init(baseView: baseView, color: color, originalBackgroundColor: originalBackgroundColor, isTemporary: isTemporary) + } + + override func showPlaceholder() { + super.showPlaceholder() + guard !isShowing else { return } + // Deactivate existing ones + NSLayoutConstraint.deactivate(layoutConstraints) + layoutConstraints = baseView.turnIntoPlaceholder(cornerRadius: cornerRadius, height: height, widthRatioToParent: widthRatioToParent) + NSLayoutConstraint.activate(layoutConstraints) + isShowing = true + } + + override func hidePlaceholder() { + super.hidePlaceholder() + baseView.resetPlaceholder(cornerRadius: originalCornerRadius) + NSLayoutConstraint.deactivate(layoutConstraints) + layoutConstraints = [] + isShowing = false + } +} diff --git a/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplaying.swift b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplaying.swift new file mode 100644 index 00000000..01b62eac --- /dev/null +++ b/Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplaying.swift @@ -0,0 +1,30 @@ +import UIKit + +/// Describes a UI element that can show a placeholder with a specific color. +@MainActor +public protocol PlaceholderDisplaying { + // If 'true', the placeholder element(or elements) will be made visible when `showPlaceholder()` is called, and will be hidden when `hidePlaceholder()` is + // called. + var isTemporary: Bool { get } + /// Color of the placeholder state. + var placeholderColor: UIColor { get set } + /// Shows the placeholder state of this object. + func showPlaceholder() + /// Hides the placeholder state of this object. Reverts any changes made by `showPlaceholder()`. + func hidePlaceholder() + /// Sets the `layer.backgroundColor` of the underlying view element. + func set(layerColor newColor: UIColor?) + /// Sets the `backgroundColor` of the underlying view element. + func set(viewColor newColor: UIColor?) + /// Prepares for color animations. + func prepareForAnimation() + /// Refreshes the color. `backgroundColor` is set to `placeholderColor` and`layer.backgroundColor` to nil. + func refreshColor() +} + +extension PlaceholderDisplaying { + func refreshColor() { + set(layerColor: .clear) + set(viewColor: placeholderColor) + } +} diff --git a/Sources/GravatarUI/ProfileView/Placeholder/ProfileViewPlaceholderDisplaying.swift b/Sources/GravatarUI/ProfileView/Placeholder/ProfileViewPlaceholderDisplaying.swift new file mode 100644 index 00000000..64f834be --- /dev/null +++ b/Sources/GravatarUI/ProfileView/Placeholder/ProfileViewPlaceholderDisplaying.swift @@ -0,0 +1,87 @@ +import UIKit + +/// Defines the interactions for showing/hiding placeholder state of the `BaseProfileView`. Placeholder state is defined as the state of `BaseProfileView` when +/// all the fields are empty. +@MainActor +public protocol ProfileViewPlaceholderDisplaying { + func showPlaceholder(on view: BaseProfileView) + func hidePlaceholder(on view: BaseProfileView) + func setup(using view: BaseProfileView) + func refresh(with placeholderColors: PlaceholderColors) +} + +/// ProfileViewPlaceholderDisplayer can convert each element of `BaseProfileView` into a placeholder and revert back. +@MainActor +class ProfileViewPlaceholderDisplayer: ProfileViewPlaceholderDisplaying { + var elements: [PlaceholderDisplaying]? + var isShowing: Bool = false + + func setup(using view: BaseProfileView) { + let color = view.placeholderColors.backgroundColor + elements = [ + BackgroundColorPlaceholderDisplayer( + baseView: view.avatarImageView, color: color, originalBackgroundColor: .clear + ), + LabelPlaceholderDisplayer( + baseView: view.aboutMeLabel, + color: color, + cornerRadius: 8, + height: 14, + widthRatioToParent: 0.8 + ), + LabelPlaceholderDisplayer( + baseView: view.aboutMePlaceholderLabel, + color: color, + cornerRadius: 8, + height: 14, + widthRatioToParent: 0.6, + isTemporary: true + ), + LabelPlaceholderDisplayer( + baseView: view.displayNameLabel, + color: color, + cornerRadius: view.displayNamePlaceholderHeight / 2, + height: view.displayNamePlaceholderHeight, + widthRatioToParent: 0.6 + ), + LabelPlaceholderDisplayer( + baseView: view.personalInfoLabel, + color: color, + cornerRadius: 8, + height: 14, + widthRatioToParent: 0.8 + ), + ProfileButtonPlaceholderDisplayer( + baseView: view.profileButton, + color: color, + cornerRadius: 8, + height: 16, + widthRatioToParent: 0.2 + ), + AccountButtonsPlaceholderDisplayer( + containerStackView: view.accountButtonsStackView, + color: color + ), + ] + } + + func showPlaceholder(on view: BaseProfileView) { + isShowing = true + elements?.forEach { $0.showPlaceholder() } + } + + func hidePlaceholder(on view: BaseProfileView) { + isShowing = false + elements?.forEach { $0.hidePlaceholder() } + } + + func refresh(with placeholderColors: PlaceholderColors) { + guard let elements else { return } + for var element in elements { + element.placeholderColor = placeholderColors.backgroundColor + if isShowing { + element.set(viewColor: placeholderColors.backgroundColor) + } + } + } +} diff --git a/Sources/GravatarUI/ProfileView/Placeholder/UIView+Placeholder.swift b/Sources/GravatarUI/ProfileView/Placeholder/UIView+Placeholder.swift new file mode 100644 index 00000000..2398363d --- /dev/null +++ b/Sources/GravatarUI/ProfileView/Placeholder/UIView+Placeholder.swift @@ -0,0 +1,32 @@ +import UIKit + +@MainActor +extension UIView { + func turnIntoPlaceholder(cornerRadius: CGFloat?, height: CGFloat?, widthRatioToParent: CGFloat?) -> [NSLayoutConstraint] { + if let cornerRadius { + layer.cornerRadius = cornerRadius + clipsToBounds = true + } + var constraints: [NSLayoutConstraint] = [] + + if let height { + let heightConstraint = heightAnchor.constraint(equalToConstant: height) + heightConstraint.priority = .required + constraints.append(heightConstraint) + } + + if let widthRatioToParent, let superview { + let widthConstraint = widthAnchor.constraint(equalTo: superview.widthAnchor, multiplier: widthRatioToParent) + widthConstraint.priority = .required + constraints.append(widthConstraint) + } + return constraints + } + + func resetPlaceholder(cornerRadius: CGFloat) { + layer.cornerRadius = cornerRadius + if cornerRadius == 0 { + clipsToBounds = false + } + } +} diff --git a/Tests/GravatarUITests/ProfileViewTests.swift b/Tests/GravatarUITests/ProfileViewTests.swift index 09502f41..66db8abe 100644 --- a/Tests/GravatarUITests/ProfileViewTests.swift +++ b/Tests/GravatarUITests/ProfileViewTests.swift @@ -45,6 +45,12 @@ extension UIView { centerXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true return containerView } + + func applySize(_ size: CGSize) { + translatesAutoresizingMaskIntoConstraints = false + widthAnchor.constraint(equalToConstant: size.width).isActive = true + heightAnchor.constraint(equalToConstant: size.height).isActive = true + } } extension UIUserInterfaceStyle { diff --git a/Tests/GravatarUITests/TestPlaceholderDisplayers.swift b/Tests/GravatarUITests/TestPlaceholderDisplayers.swift new file mode 100644 index 00000000..69af344e --- /dev/null +++ b/Tests/GravatarUITests/TestPlaceholderDisplayers.swift @@ -0,0 +1,101 @@ +import Gravatar +@testable import GravatarUI +import SnapshotTesting +import XCTest + +final class TestPlaceholderDisplayers: XCTestCase { + enum Constants { + static let elementSize = CGSize(width: 40, height: 20) + static let containerWidth = elementSize.width * 2 + } + + override func setUpWithError() throws { + try super.setUpWithError() + // isRecording = true + } + + @MainActor + func testBackgroundColorPlaceholderDisplayer() throws { + let view = UIView(frame: .zero) + view.applySize(Constants.elementSize) + let containerView = view.wrapInSuperView(with: Constants.containerWidth) + let placeholderDisplayer = BackgroundColorPlaceholderDisplayer( + baseView: view, + color: .porpoiseGray, + originalBackgroundColor: .white + ) + placeholderDisplayer.showPlaceholder() + assertSnapshot(of: containerView, as: .image, named: "placeholder-shown") + placeholderDisplayer.hidePlaceholder() + assertSnapshot(of: containerView, as: .image, named: "placeholder-hidden") + } + + @MainActor + func testBackgroundColorPlaceholderDisplayerTemporaryField() throws { + let view = UIView(frame: .zero) + view.isHidden = true + view.applySize(Constants.elementSize) + let placeholderDisplayer = BackgroundColorPlaceholderDisplayer( + baseView: view, + color: .porpoiseGray, + originalBackgroundColor: .white, + isTemporary: true + ) + placeholderDisplayer.showPlaceholder() + XCTAssertFalse(view.isHidden) + placeholderDisplayer.hidePlaceholder() + XCTAssertTrue(view.isHidden) + } + + @MainActor + func testRectangularColorPlaceholderDisplayer() throws { + let view = UIView(frame: .zero) + view.applySize(Constants.elementSize) + let containerView = view.wrapInSuperView(with: Constants.containerWidth) + let placeholderDisplayer = RectangularPlaceholderDisplayer( + baseView: view, + color: .porpoiseGray, + originalBackgroundColor: .white, + cornerRadius: 8, + height: Constants.elementSize.height, + widthRatioToParent: 0.8 + ) + placeholderDisplayer.showPlaceholder() + assertSnapshot(of: containerView, as: .image, named: "placeholder-shown") + placeholderDisplayer.hidePlaceholder() + assertSnapshot(of: containerView, as: .image, named: "placeholder-hidden") + } + + @MainActor + func testProfileButtonPlaceholderDisplayer() throws { + let button = UIButton(frame: .zero) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("View profile", for: .normal) + button.setImage(UIImage(systemName: "star.fill"), for: .normal) + let containerView = button.wrapInSuperView(with: 120) + let placeholderDisplayer = ProfileButtonPlaceholderDisplayer( + baseView: button, + color: .porpoiseGray, + originalBackgroundColor: .dugongGray, + cornerRadius: 8, + height: 30, + widthRatioToParent: 0.8 + ) + placeholderDisplayer.showPlaceholder() + assertSnapshot(of: containerView, as: .image, named: "placeholder-shown") + placeholderDisplayer.hidePlaceholder() + assertSnapshot(of: containerView, as: .image, named: "placeholder-hidden") + } + + @MainActor + func testAccountButtonsPlaceholderDisplayer() throws { + let stackView = UIStackView(frame: .zero) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = 4 + let placeholderDisplayer = AccountButtonsPlaceholderDisplayer(containerStackView: stackView, color: .porpoiseGray) + placeholderDisplayer.showPlaceholder() + assertSnapshot(of: stackView, as: .image, named: "placeholder-shown") + placeholderDisplayer.hidePlaceholder() + XCTAssertEqual(stackView.frame.size, .zero) + } +} diff --git a/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testAccountButtonsPlaceholderDisplayer.placeholder-shown.png b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testAccountButtonsPlaceholderDisplayer.placeholder-shown.png new file mode 100644 index 00000000..8f8b0836 Binary files /dev/null and b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testAccountButtonsPlaceholderDisplayer.placeholder-shown.png differ diff --git a/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testBackgroundColorPlaceholderDisplayer.placeholder-hidden.png b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testBackgroundColorPlaceholderDisplayer.placeholder-hidden.png new file mode 100644 index 00000000..178fdb5c Binary files /dev/null and b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testBackgroundColorPlaceholderDisplayer.placeholder-hidden.png differ diff --git a/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testBackgroundColorPlaceholderDisplayer.placeholder-shown.png b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testBackgroundColorPlaceholderDisplayer.placeholder-shown.png new file mode 100644 index 00000000..076cbe0c Binary files /dev/null and b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testBackgroundColorPlaceholderDisplayer.placeholder-shown.png differ diff --git a/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testProfileButtonPlaceholderDisplayer.placeholder-hidden.png b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testProfileButtonPlaceholderDisplayer.placeholder-hidden.png new file mode 100644 index 00000000..d9e8e07e Binary files /dev/null and b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testProfileButtonPlaceholderDisplayer.placeholder-hidden.png differ diff --git a/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testProfileButtonPlaceholderDisplayer.placeholder-shown.png b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testProfileButtonPlaceholderDisplayer.placeholder-shown.png new file mode 100644 index 00000000..5b79e4f1 Binary files /dev/null and b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testProfileButtonPlaceholderDisplayer.placeholder-shown.png differ diff --git a/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testRectangularColorPlaceholderDisplayer.placeholder-hidden.png b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testRectangularColorPlaceholderDisplayer.placeholder-hidden.png new file mode 100644 index 00000000..178fdb5c Binary files /dev/null and b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testRectangularColorPlaceholderDisplayer.placeholder-hidden.png differ diff --git a/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testRectangularColorPlaceholderDisplayer.placeholder-shown.png b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testRectangularColorPlaceholderDisplayer.placeholder-shown.png new file mode 100644 index 00000000..002ac674 Binary files /dev/null and b/Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testRectangularColorPlaceholderDisplayer.placeholder-shown.png differ