From 4984c0c4a5ab9b39e9928898a573003bd5500442 Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Tue, 23 Apr 2024 13:12:35 +0300 Subject: [PATCH] Add placeholder displayers for profile fields (#222) * Add placeholder displaying types * Add unit tests * Update doc * swiftformat * Fix warning * Small update * Update snapshots * Doc update --------- Co-authored-by: Pinar Olguc --- Sources/GravatarUI/DesignSystem/Palette.swift | 39 ++++++- .../DesignSystem/UIColor+DesignSystem.swift | 4 + .../ProfileView/BaseProfileView.swift | 40 ++++++- .../ProfileView/LargeProfileSummaryView.swift | 5 + .../ProfileView/LargeProfileView.swift | 5 + .../AccountButtonsPlaceholderDisplayer.swift | 63 +++++++++++ .../BackgroundColorPlaceholderDisplayer.swift | 45 ++++++++ .../LabelPlaceholderDisplayer.swift | 10 ++ .../ProfileButtonPlaceholderDisplayer.swift | 17 +++ .../RectangularPlaceholderDisplayer.swift | 48 +++++++++ .../Placeholder/PlaceholderDisplaying.swift | 30 ++++++ .../ProfileViewPlaceholderDisplaying.swift | 87 +++++++++++++++ .../Placeholder/UIView+Placeholder.swift | 32 ++++++ Tests/GravatarUITests/ProfileViewTests.swift | 6 ++ .../TestPlaceholderDisplayers.swift | 101 ++++++++++++++++++ ...PlaceholderDisplayer.placeholder-shown.png | Bin 0 -> 2116 bytes ...laceholderDisplayer.placeholder-hidden.png | Bin 0 -> 954 bytes ...PlaceholderDisplayer.placeholder-shown.png | Bin 0 -> 952 bytes ...laceholderDisplayer.placeholder-hidden.png | Bin 0 -> 5201 bytes ...PlaceholderDisplayer.placeholder-shown.png | Bin 0 -> 1761 bytes ...laceholderDisplayer.placeholder-hidden.png | Bin 0 -> 954 bytes ...PlaceholderDisplayer.placeholder-shown.png | Bin 0 -> 1361 bytes 22 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/AccountButtonsPlaceholderDisplayer.swift create mode 100644 Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/BackgroundColorPlaceholderDisplayer.swift create mode 100644 Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/LabelPlaceholderDisplayer.swift create mode 100644 Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/ProfileButtonPlaceholderDisplayer.swift create mode 100644 Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplayers/RectangularPlaceholderDisplayer.swift create mode 100644 Sources/GravatarUI/ProfileView/Placeholder/PlaceholderDisplaying.swift create mode 100644 Sources/GravatarUI/ProfileView/Placeholder/ProfileViewPlaceholderDisplaying.swift create mode 100644 Sources/GravatarUI/ProfileView/Placeholder/UIView+Placeholder.swift create mode 100644 Tests/GravatarUITests/TestPlaceholderDisplayers.swift create mode 100644 Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testAccountButtonsPlaceholderDisplayer.placeholder-shown.png create mode 100644 Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testBackgroundColorPlaceholderDisplayer.placeholder-hidden.png create mode 100644 Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testBackgroundColorPlaceholderDisplayer.placeholder-shown.png create mode 100644 Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testProfileButtonPlaceholderDisplayer.placeholder-hidden.png create mode 100644 Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testProfileButtonPlaceholderDisplayer.placeholder-shown.png create mode 100644 Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testRectangularColorPlaceholderDisplayer.placeholder-hidden.png create mode 100644 Tests/GravatarUITests/__Snapshots__/TestPlaceholderDisplayers/testRectangularColorPlaceholderDisplayer.placeholder-shown.png 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 0000000000000000000000000000000000000000..8f8b08368722ba4fe130797c21fd718124d4e3b8 GIT binary patch literal 2116 zcmZWp2{_bS8~@K@znTV362?}F?6Rch4q;4UBxzz=M_Ia+h`44*V~HqHjS53)i*005{G zJzRYO0Hz7)ctv?=Y&HtH4FN3Dm*4_ac51(eCP_!h#A9AwfC;240&*}l0I@`YIsnrG zy`vY^fj7+jM|~DBtHnDfXht}8aF_)gpeSH2ODpEhOnII>g*Q- zljLuU5aNGIzVqt0Ppk6m>^-L1l_JmwT^{&M<1#CU{WG<`ttyK^^l2%OFsf=V&6HfV7}PtMM+`&=fdQnFhH^5$c{3gc+@ zoVenu0@}!aJWkxBUrjNet`|;Opff}U7=;@Ls`6Hl75+?)injABO{9(J5O88DIyZgg z!b<&pl#T0I=pwm#{k5!s6+tHrSuqM%P+%^y3^@+evRXT&aR#2@#dT3n~cAVZHX^lF`MSRyc}-F-Z()SpLwII zcUl2_ey%;0MuA(qG|g=so6xs+qD3KyJZGF0c~&3GJZIFp$sP^1VZTV^ZNnipewm%E z{UU=RxQ!4EHpzd`V`&e7d5hY0?OL`PHp_XwzMb(#hAljh5HD_1uY!xu81A z-5hL-QaKwu6QRy9TGShB2&!e$t|s>yU@xW%?v5KidmzC+v#bRSj(H_ExIu5H^XD-V z&Eeho%i6ikJ>PROG$Qs}9J%z;J+7Uwk8C!}DJ`uC!^KQzT)?yTUc73uGqsC*HjP&6 zQ;q@2emwte*j;GP`&6jLKZ%f&re)#FP!#+L3mwDMs-CTtz{=;6Ay z*4wx1KY$mWVr2M@{jqd{B^H;JPOByV?F=@I%M0M36)WlwSMy znMTf$uE7~in^#FNi|)`%AHBhStB+McL4h9g0`M0GJu_OoajHsj-Z(pEOMnwA0V>_^ z-xb|#q3;P1yHvMRgHG83yoQ|5TNC4& z%xO74wal+}2jh73vZr6qto_fWf+0nEq2L$0D&ds$(Q{*Jl^RMGq9@}U`#VJoS8IVA z?l)P6=j-lyfs5fA!|TR5q@-4mVPaz%g*YPNJTDy=vae6*3fRIs1%X@?uwHI;E-_z` zQ$#;vEd!+-QhB_ky~)C7waF|Qt);T3_&y2V1{No#&pV^nj2-ZBrESmGeMq&GqCGmX ziHwpFy3b+O3;Eo+W-o|MeV{Nc)bG?dX%tXU9N<{Cz* zp%-;5rl0AEMx|Iy@0;=Xw{IIJ8Zge)p+L{V+Z^KK6%~hO&H3h*XGbAh7T+xf6$Ic zwMqFZ)S&V$I^Ka(kzs!nP+9dY?;GfM!_F~wJ&Cli&OqNw2)_w$ca*+ym2$cs4m^gw zkNE+WpQyapm8a@R11VRji$af9Y_6xk-`%c@3< zW{ZE-`{Sw7HxW|KWena_`IW;n0~>Go_+tvPkPR|EL3x<_Etr<~CCBs1;Y}GtdAf5k z-S#?NQY_c~J9*P%vF{;GPwQb7Cq9AE---(WDjd#=WPys|elmh}w<%i#wANP;MiAHC?*PMTJJBMz2b190n0x{&AmidiE??s3v z!~4d}?9B_*h1#8a^4yr+H3e^&8d$0CfQt3byA2_$?Xz*DpG>>?q_3~ zgC)iVR!s%+2^GWFS+238$7kxk+?<=7r-xu4$xl(SX5C@$rpF3BW?EmSXg(fxY>rHM zZdY1Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..178fdb5ce73e001fe6d4c84244e134ac52120469 GIT binary patch literal 954 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGBgAGW=O>_%)lU~3c`$@K`I}Bf-;#d{vkjbXdxJA z07)dwnE&74a68aeYfl%)kP5~(*9^H1I|wj4{Qti*FUV>Mf7Ti=^R_8*H(Z5(?s|Xz z@pCq#5BAK{5C5_6*}dbmK667xK)iq=b3@DH3_)Ec4h1Qd6ov@}GQ5i%85mufTX-0R z`W8Eya40lnOqjsvaH!CvgN>0zfLF+f!Na1D?V>^h!y-o~HU`e)7Zs#f1RP8}PB17u zw2*RXW?<@I(cAX%rar4NBMO|mI--_jxA$kIuWsC_iF|w5A(p}%M6SuI+yO2GB7Tgy<%f# z1A~#+%Xd}{43~Vb+%R!qknAogmvvxxsg{)^rO?phx@$L|LPLqLsTr?;!!f11vFrj4 zyE>(2vvDXSPkgtI8DgC^?+I}RhX4N$xjmEy24ey!7~kG8 z%8_GIa7av}0*mG`>-=5?mXn%V>l_4{L>|xG?k3RmL_{=Pfy1%uPmqosGK;!;Mn%iqxD?KjXTLCY#c^B>6y!DZ85^!na;SorY=nHw_ x(|UHVP>X_4NO>_%)lU~3c`$@K`I}Bf-;#d{vkjbXdxJA z07)dwSWskft_WzWji-xaNCo4Y8-{$%4m_@o@9K}(viW)m_)0C-ckW!iU~%Wu?VD3> zf4~3GklDImKlAK^cleLRoOybSu~Fa(uhN?aheHJ#PJ)dbEeeV3EJAJ00!_iF^YfazzvYVDHt)EQ;9ul~d}0d0SHK>%oxl0f*HbESu@ zzv?G~1X&b0WLNRcoe5z&S}k8%zRCzF=*ZzDU=<>3Wej09mH2tT4@m_If;5$^uqsJ2 z@|_^e!0`V+cj4?wz;KKOh2z^hib5?8JT8In>RtL8BTtHPT@UT`ndcyAyz<{qNtWZK zf8Y6@ubj92c{RUT>Ql?t8{Zyy@PqI8<=wfJj2w$Y+*nV1nA+_qv7~X1qeP2ilL!No zKm)^*zd`9oHzh@GJmkW_;lRK&AuBhl%}sb6=hk-$?-WFRgq`lsVN_sX^tfRGR(~XX z&9iwEvu1xyanfxz-~b^NZIkzE5eqGup?s5R9$U|TnHz9gf#(nio#Z;Zn=>rq+yOBE zlhl%#a#>$<=Q_5;y_n6zpz>mC=8{`8A26~gy#5z*iO0Bsfki=)nSt|crdn^5_B2oC zWu=k{0t@5Ce;xbAz{ug?#Ks^r>#~m&(1B{aGp?mJIJVTqzL37n%%Z@+DaqiG;b)wA zWmoeIlZ2@0jBJ9xN*Ctm@B@9SlER>{(DLk(z}I|9ml`r-*(6+ETyNdC@vpqxO_r46 Sx@FfvN!ioY&t;ucLK6UBf+4s7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d9e8e07e1b8db125be628bfc2d82c24336041d0a GIT binary patch literal 5201 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 L_i^nbM8v-UVU&~} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5b79e4f114f7d4a3dab2d5e1ad802109241bd371 GIT binary patch literal 1761 zcmeAS@N?(olHy`uVBq!ia0y~yV3Yu|Q#jawWUQoq6Odvo4sv&5Sa(k5C6LpS8sVAd z>&u`8WOFdEG72#;gOmUv1EUlJE11o|;Ke8nX9qEAK-DlYFtlefut3#B0cjAJ0K`Du zP?~uGBf`W5Ofc1q5(}7NY=#t&HsxAEakt!T9FRLBB%|BCUz{{tK?0A@Qgw!6f5K>ep9? zT2me8T+aL$y}{6E((V6$Y%hzMpFjV8@pQ=q24+q+vrgBwoMAi(3nUaIc>S7>I`l9$ zF^2Q#>AH0*N;D}-@Eie36dZkUDZvE9Z8`{3!0Xmb2L+L968x5L|M>D{s*Q}?vA_T7 z?!3)Ao+xqbZQ1Kow*S)_PdTJ6Gzz@Aq9-J5n%3u|oXgrmo2A=k*K-SYT6M-8a!6fj zwAtmNj+epl$B&nXY?B8( zbP+O=ZV+t=2*7P?VT9s;Sh;GItPzlc)KWH z{NNSaY6Vq?O=|0wNv=5a^@i42y%|~ha*JlSIar)gJ?fCU?)ci*H(7)G&6c-i#4;W| zP|2oMUA%q9P1fY<5;=y42D4R|ZMS)D=3cgM$yAv|TU$k#FY|w%ml?&nn1i0;+Hq2PF#UL>tHhQ}qA; zOd{R3z~uZNl$_rJ6Z2yS0oTBP|AliMoCLX#aEJ(;F9tgO34Ab0ZU*QuSr-XHTE<@8EhMWveT@ zK0dkYjj#TU@8A7FnNY+);O6W&U?TpW8T+0wjA2ID#r2^*wx{z+t3@K^|LGe)UXXYx_2Z5xj=J^(RVizaM_r0L#UjjJS6f>=`-vGRGqXg; z8rNr9cB;H-yDuzX&U}E4VVPu#Y4bmObE9of*FM@EVoCf9y`#BH{b(5YL>apF(fNDcyhCWa<6v_mi@hR?C-^UCzI+O>_%)lU~3c`$@K`I}Bf-;#d{vkjbXdxJA z07)dwnE&74a68aeYfl%)kP5~(*9^H1I|wj4{Qti*FUV>Mf7Ti=^R_8*H(Z5(?s|Xz z@pCq#5BAK{5C5_6*}dbmK667xK)iq=b3@DH3_)Ec4h1Qd6ov@}GQ5i%85mufTX-0R z`W8Eya40lnOqjsvaH!CvgN>0zfLF+f!Na1D?V>^h!y-o~HU`e)7Zs#f1RP8}PB17u zw2*RXW?<@I(cAX%rar4NBMO|mI--_jxA$kIuWsC_iF|w5A(p}%M6SuI+yO2GB7Tgy<%f# z1A~#+%Xd}{43~Vb+%R!qknAogmvvxxsg{)^rO?phx@$L|LPLqLsTr?;!!f11vFrj4 zyE>(2vvDXSPkgtI8DgC^?+I}RhX4N$xjmEy24ey!7~kG8 z%8_GIa7av}0*mG`>-=5?mXn%V>l_4{L>|xG?k3RmL_{=Pfy1%uPmqosGK;!;Mn%iqxD?KjXTLCY#c^B>6y!DZ85^!na;SorY=nHw_ x(|UHVP>X_4NO>_%)lU~3c`$@K`I}Bf-;#d{vkjbXdxJA z07)dwWW7V0btO=m-P6S}q=NCytby`|w{=?3D+8WA^DLMR z6~vCT+z?PsN?m($)oul=$hj4o`W+vRDBUp@>=HHH+@=xBYB|^L-;xEY?;4x$?~M(d zqSaQl%~RYl<=;btOA;n-!fZ!^)|+*58~Min&-O|?bbIE6`SMR4e~WCKnm2ic!loGY zPZ<(_Y@N;;7ED#y-Pq4{XcO0l=LhmCeR@=*Rn$(DI`b*$CMK+@oHt4Odxt_)RN>aF zfAcw?bjEn)T$U4bxxR)`k&!7`uTb@wkoVeWO)G47aB`gRR#@KGz4OS@`_GzQTx%<$Bd&S0!SpnNrc1VqWuh!({o!9pUK9K%zH_5wd2j`+j{ptG4BGc5u z*8ZbZJZ18eokn++Jl9Ne(Wz8-_nWBJ^ki}_clo`%$i`3qk9qFbuQ&47{pQPhk@sZH z-p$XFl{!C#A3WMMBXg?M`bVbXD)VE^SI0A#JpOgkr`W4*s=E{{$=V({F{eyeC7z*r z{`c=!t}a{0@#sq@jiwuPi?{=sO+ W-Tp<~>x>?#jPZ2!b6Mw<&;$StQ~Q