Skip to content

Commit

Permalink
Add some avatar customization options in ProfileViewConfiguration (#270)
Browse files Browse the repository at this point in the history
* Add some customizations for the avatar

* Update avatar placeholder displayer

* Small update

* Update snapshots

* swiftformat

* make update-example-snapshots

* Resolve unused warnings
  • Loading branch information
pinarol authored Jun 6, 2024
1 parent ea26cb4 commit 25bf048
Show file tree
Hide file tree
Showing 43 changed files with 171 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ class DemoProfilePresentationStylesViewController: DemoBaseProfileViewController
return button
}()

private lazy var customizeAvatarSwitchWithLabel: SwitchWithLabel = {
let view = SwitchWithLabel(labelText: "Customize Avatar")
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()

override func viewDidLoad() {
super.viewDidLoad()
title = "Profile View Controller"
[emailField, profileStylesButton, paletteButton, showBottomSheetButton].forEach(rootStackView.addArrangedSubview)
[emailField, profileStylesButton, paletteButton, customizeAvatarSwitchWithLabel, showBottomSheetButton].forEach(rootStackView.addArrangedSubview)
rootStackView.alignment = .center
}

@objc func showBottomSheetButtonHandler() {
Expand Down Expand Up @@ -68,16 +75,25 @@ class DemoProfilePresentationStylesViewController: DemoBaseProfileViewController
}

func newConfig() -> ProfileViewConfiguration {
var config: ProfileViewConfiguration
switch preferredProfileStyle {
case .large:
return ProfileViewConfiguration.large(palette: preferredPaletteType)
config = ProfileViewConfiguration.large(palette: preferredPaletteType)
case .largeSummary:
return ProfileViewConfiguration.largeSummary(palette: preferredPaletteType)
config = ProfileViewConfiguration.largeSummary(palette: preferredPaletteType)
case .standard:
return ProfileViewConfiguration.standard(palette: preferredPaletteType)
config = ProfileViewConfiguration.standard(palette: preferredPaletteType)
case .summary:
return ProfileViewConfiguration.summary(palette: preferredPaletteType)
config = ProfileViewConfiguration.summary(palette: preferredPaletteType)
}
if customizeAvatarSwitchWithLabel.isOn {
config.avatarConfiguration.borderColor = .green
config.avatarConfiguration.borderWidth = 3
config.avatarConfiguration.cornerRadiusCalculator = { avatarLength in
return avatarLength / 8
}
}
return config
}

func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
Expand Down
Binary file modified ...I/GravatarUI.docc/Resources/ProfileExamples/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ...vatarUI.docc/Resources/ProfileExamples/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ...rUI/GravatarUI.docc/Resources/ProfileExamples/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified ...I/GravatarUI.docc/Resources/ProfileExamples/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
112 changes: 88 additions & 24 deletions Sources/GravatarUI/ProfileView/BaseProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,19 +187,29 @@ open class BaseProfileView: UIView, UIContentView {
}
}

private var avatarLength: CGFloat {
didSet {
if let avatarProvider = avatarProvider as? DefaultAvatarProvider {
avatarProvider.avatarLength = avatarLength
}
}
}

public init(
frame: CGRect = .zero,
paletteType: PaletteType? = nil,
profileButtonStyle: ProfileButtonStyle? = nil,
avatarType: AvatarType? = nil,
avatarLength: CGFloat? = nil,
padding: UIEdgeInsets? = nil
) {
self.paletteType = paletteType ?? .system
self.avatarLength = avatarLength ?? Self.avatarLength
let placeholderDisplayer = ProfileViewPlaceholderDisplayer()
self.placeholderDisplayer = placeholderDisplayer
self.activityIndicator = ProfilePlaceholderActivityIndicator(placeholderDisplayer: placeholderDisplayer)
self.avatarType = (avatarType ?? AvatarType.imageView(UIImageView()))
self.avatarProvider = self.avatarType.avatarProvider(avatarLength: Self.avatarLength, paletteType: self.paletteType)
self.avatarProvider = self.avatarType.avatarProvider(avatarLength: self.avatarLength, paletteType: self.paletteType)
self.profileButtonStyle = profileButtonStyle ?? self.profileButtonStyle
super.init(frame: frame)
self.padding = padding ?? Self.defaultPadding
Expand Down Expand Up @@ -245,7 +255,7 @@ open class BaseProfileView: UIView, UIContentView {
avatarProvider.setImage(placeholder)
return
}
let pointsSize: ImageSize = .points(Self.avatarLength)
let pointsSize: ImageSize = .points(avatarLength)
let downloadOptions = ImageSettingOptions(options: options).deriveDownloadOptions(
garavatarRating: rating,
preferredSize: pointsSize,
Expand Down Expand Up @@ -342,6 +352,14 @@ open class BaseProfileView: UIView, UIContentView {
isLoading = config.isLoading
avatarActivityIndicatorType = config.avatarConfiguration.activityIndicatorType
delegate = config.delegate
if let avatarProvider = avatarProvider as? DefaultAvatarProvider {
avatarProvider.cornerRadiusCalculator = config.avatarConfiguration.cornerRadiusCalculator
avatarProvider.avatarBorderWidth = config.avatarConfiguration.borderWidth
avatarProvider.avatarBorderColor = config.avatarConfiguration.borderColor
}
if let length = config.avatarConfiguration.avatarLength {
avatarLength = length
}
loadAvatar(with: config)
if config.model != nil || config.summaryModel != nil {
profileButtonStyle = config.profileButtonStyle
Expand Down Expand Up @@ -422,23 +440,52 @@ public protocol ProfileViewDelegate: NSObjectProtocol {
func profileView(_ view: BaseProfileView, didTapOnAvatarWithID avatarID: AvatarIdentifier?)
}

public typealias AvatarCornerRadiusCalculator = @Sendable (_ avatarLength: CGFloat) -> CGFloat

enum AvatarConstants {
static let cornerRadiusCalculator: AvatarCornerRadiusCalculator = { avatarLength in
avatarLength / 2
}
}

@MainActor
class DefaultAvatarProvider: AvatarProviding {
private let avatarLength: CGFloat
private let avatarImageView: UIImageView
private let baseView: UIView
private let skipStyling: Bool
private(set) var paletteType: PaletteType
private var widthConstraint: NSLayoutConstraint?
private var heightConstraint: NSLayoutConstraint?

var cornerRadiusCalculator: AvatarCornerRadiusCalculator {
didSet {
avatarCornerRadius = cornerRadiusCalculator(avatarLength)
}
}

var avatarCornerRadius: CGFloat {
var avatarLength: CGFloat {
didSet {
avatarImageView.layer.cornerRadius = avatarCornerRadius
guard avatarLength != oldValue else { return }
avatarCornerRadius = cornerRadiusCalculator(avatarLength)
applyLength()
}
}

private var avatarCornerRadius: CGFloat {
didSet {
applyCornerRadius()
}
}

var avatarBorderWidth: CGFloat {
didSet {
avatarImageView.layer.borderWidth = avatarBorderWidth
applyBorderWidth()
}
}

var avatarBorderColor: UIColor? {
didSet {
applyBorderColor()
}
}

Expand All @@ -448,18 +495,44 @@ class DefaultAvatarProvider: AvatarProviding {
}
}

private func applyBorderWidth() {
guard !skipStyling else { return }
avatarImageView.layer.borderWidth = avatarBorderWidth
}

private func applyBorderColor() {
guard !skipStyling else { return }
avatarImageView.layer.borderColor = (avatarBorderColor ?? paletteType.palette.avatar.border).cgColor
}

private func applyCornerRadius() {
guard !skipStyling else { return }
avatarImageView.layer.cornerRadius = avatarCornerRadius
}

private func applyLength() {
guard !skipStyling else { return }
widthConstraint?.isActive = false
heightConstraint?.isActive = false
widthConstraint = baseView.widthAnchor.constraint(equalToConstant: avatarLength)
heightConstraint = baseView.heightAnchor.constraint(equalToConstant: avatarLength)
widthConstraint?.isActive = true
heightConstraint?.isActive = true
}

init(
baseView: UIView,
avatarImageView: UIImageView,
skipStyling: Bool,
avatarLength: CGFloat,
cornerRadius: CGFloat? = nil,
cornerRadiusCalculator: AvatarCornerRadiusCalculator? = nil,
borderWidth: CGFloat = 1,
paletteType: PaletteType = .system
) {
self.avatarLength = avatarLength
self.paletteType = paletteType
self.avatarCornerRadius = cornerRadius ?? avatarLength / 2
self.cornerRadiusCalculator = cornerRadiusCalculator ?? AvatarConstants.cornerRadiusCalculator
self.avatarCornerRadius = self.cornerRadiusCalculator(avatarLength)
self.avatarBorderWidth = borderWidth
self.avatarImageView = avatarImageView
self.baseView = baseView
Expand All @@ -470,26 +543,17 @@ class DefaultAvatarProvider: AvatarProviding {
private func configure() {
guard !skipStyling else { return }
baseView.translatesAutoresizingMaskIntoConstraints = false
baseView.widthAnchor.constraint(equalToConstant: avatarLength).isActive = true
baseView.heightAnchor.constraint(equalToConstant: avatarLength).isActive = true
avatarImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
avatarImageView.layer.cornerRadius = avatarCornerRadius
avatarImageView.setContentHuggingPriority(.defaultHigh, for: .vertical)
applyLength()
applyBorderWidth()
applyBorderColor()
applyCornerRadius()
avatarImageView.clipsToBounds = true
}

func setImage(with source: URL?, placeholder: UIImage?, options: [ImageSettingOption]?) async throws {
do {
let _ = try await avatarImageView.gravatar.setImage(with: source, placeholder: placeholder, options: options)
if !skipStyling {
avatarImageView.layer.borderColor = paletteType.palette.avatar.border.cgColor
avatarImageView.layer.borderWidth = avatarBorderWidth
}
} catch {
if !skipStyling {
avatarImageView.layer.borderColor = UIColor.clear.cgColor
}
throw error
}
let _ = try await avatarImageView.gravatar.setImage(with: source, placeholder: placeholder, options: options)
}

func setImage(_ image: UIImage?) {
Expand All @@ -499,7 +563,7 @@ class DefaultAvatarProvider: AvatarProviding {
func refresh(with paletteType: PaletteType) {
guard !skipStyling else { return }
self.paletteType = paletteType
avatarImageView.layer.borderColor = paletteType.palette.avatar.border.cgColor
applyBorderColor()
avatarImageView.backgroundColor = paletteType.palette.avatar.background
avatarImageView.overrideUserInterfaceStyle = paletteType.palette.preferredUserInterfaceStyle
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,19 @@ class BackgroundColorPlaceholderDisplayer<T: UIView>: PlaceholderDisplaying {
func animationWillBegin() {}
func animationDidEnd() {}
}

@MainActor
class AvatarPlaceholderDisplayer<T: UIView>: BackgroundColorPlaceholderDisplayer<T> {
private var originalBorderWidth: CGFloat = 0

override func showPlaceholder() {
super.showPlaceholder()
originalBorderWidth = baseView.layer.borderWidth
baseView.layer.borderWidth = 0
}

override func hidePlaceholder() {
super.hidePlaceholder()
baseView.layer.borderWidth = originalBorderWidth
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class ProfileViewPlaceholderDisplayer: ProfileViewPlaceholderDisplaying {
let imageView = view.avatarType.imageView
{
elements?.append(
BackgroundColorPlaceholderDisplayer<UIView>(
AvatarPlaceholderDisplayer<UIView>(
baseView: imageView,
color: color,
originalBackgroundColor: view.paletteType.palette.avatar.background
Expand Down
19 changes: 15 additions & 4 deletions Sources/GravatarUI/ProfileView/ProfileViewConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import UIKit
/// ProfileViewConfiguration.large() // LargeProfileView
/// ProfileViewConfiguration.largeSummary() // LargeProfileSummaryView
/// ```
/// See: ``ProfileView``, ``ProfileSummaryView``, ``LargeProfileView``, ``LargeProfileSummaryView``.
/// After creating a configuration, you can modify any of the available fields to properly configure the profile view.
///
@MainActor
Expand Down Expand Up @@ -94,17 +95,27 @@ extension ProfileViewConfiguration {
extension ProfileViewConfiguration {
/// A configuration that specifies the behavior and loading options for the profile avatar.
public struct AvatarConfiguration {
/// The activity indicator used on the image view while the avatar is loading.
/// The activity indicator used on the image view while the avatar is loading. See ``ActivityIndicatorType`` for more info.
public var activityIndicatorType: ActivityIndicatorType = .activity
/// An image to be displayed while an avatar image has not been set.
public var placeholder: UIImage? = nil
/// The maximum rating of the avatar for it to be displayed. See `Gravatar.Rating` for more info.
/// The maximum rating of the avatar for it to be displayed. See ``Rating`` for more info.
public var rating: Rating? = nil
/// The avatar style to be displayed when no avatar has been found
/// See `Gravatar.DefaultAvatarOption` for more info.
/// See ``DefaultAvatarOption`` for more info.
public var defaultAvatarOption: DefaultAvatarOption? = nil
/// Options for fetchingg the avatar image. See `Gravatar.ImageSettingOption` for more info.
/// Options for fetchingg the avatar image. See ``ImageSettingOption`` for more info.
public var settingOptions: [ImageSettingOption]? = nil
/// A closure that calculates the corner radius of avatar based on its length.
/// By default, the avatar is circle shaped.
public var cornerRadiusCalculator: AvatarCornerRadiusCalculator = AvatarConstants.cornerRadiusCalculator
/// The border width of the avatar.
public var borderWidth: CGFloat = 1
/// The border color of the avatar. If not set, the border color from the palette is used. See ``Palette`` . ``Palette/avatar`` .
/// ``AvatarColors/border``.
public var borderColor: UIColor? = nil
/// Length of the avatar. If not set, a suitable length is chosen according to the ``ProfileViewConfiguration/Style``.
public var avatarLength: CGFloat? = nil
}
}

Expand Down
4 changes: 2 additions & 2 deletions Tests/GravatarTests/ProfileServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ final class ProfileServiceTests: XCTestCase {

do {
_ = try await service.fetch(with: .hashID(""))
let request = await session.request
let _ = await session.request
XCTFail()
} catch APIError.decodingError {
// Success
Expand All @@ -49,7 +49,7 @@ final class ProfileServiceTests: XCTestCase {
let service = ProfileService(client: URLSessionHTTPClient(urlSession: session))

do {
let response = try await service.fetch(with: .hashID(""))
let _ = try await service.fetch(with: .hashID(""))
XCTFail()
} catch APIError.responseError(reason: let reason) where reason.httpStatusCode == 404 {
// Expected error has occurred.
Expand Down
28 changes: 28 additions & 0 deletions Tests/GravatarUITests/ProfileConfigurationTests.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import GravatarUI
import SnapshotTesting
import XCTest

final class TestProfileConfiguration: XCTestCase {
enum Constants {
static let width: CGFloat = 320
}

override func setUp() async throws {
try await super.setUp()
// isRecording = true
}

@MainActor
func testUpdatePaddingConfigurationOnStandardViewStyle() throws {
let expectedPadding = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
Expand Down Expand Up @@ -88,4 +98,22 @@ final class TestProfileConfiguration: XCTestCase {

XCTAssertEqual(profileView.profileButton.titleLabel?.text, "Edit profile")
}

@MainActor
func testCustomAvatarStyle() throws {
let model = TestProfileCardModel.summaryCard()
var config = ProfileViewConfiguration.largeSummary(model: model)
let view = config.makeContentView()
view.translatesAutoresizingMaskIntoConstraints = false
config.palette = .light
config.avatarConfiguration.borderWidth = 2
config.avatarConfiguration.borderColor = .green
config.avatarConfiguration.cornerRadiusCalculator = { avatarLength in
avatarLength / 10
}
config.avatarConfiguration.avatarLength = 80
view.configuration = config
let superView = view.wrapInSuperView(with: Constants.width)
assertSnapshot(of: superView, as: .image)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 25bf048

Please sign in to comment.