// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import GRDB import YYImage public final class ProfilePictureView: UIView { public struct Info { let imageData: Data? let renderingMode: UIImage.RenderingMode let themeTintColor: ThemeValue? let inset: UIEdgeInsets let icon: ProfileIcon let backgroundColor: ThemeValue? let forcedBackgroundColor: ForcedThemeValue? public init( imageData: Data?, renderingMode: UIImage.RenderingMode = .automatic, themeTintColor: ThemeValue? = nil, inset: UIEdgeInsets = .zero, icon: ProfileIcon = .none, backgroundColor: ThemeValue? = nil, forcedBackgroundColor: ForcedThemeValue? = nil ) { self.imageData = imageData self.renderingMode = renderingMode self.themeTintColor = themeTintColor self.inset = inset self.icon = icon self.backgroundColor = backgroundColor self.forcedBackgroundColor = forcedBackgroundColor } } public enum Size { case navigation case message case list case hero public var viewSize: CGFloat { switch self { case .navigation, .message: return 26 case .list: return 46 case .hero: return 110 } } public var imageSize: CGFloat { switch self { case .navigation, .message: return 26 case .list: return 46 case .hero: return 80 } } public var multiImageSize: CGFloat { switch self { case .navigation, .message: return 18 // Shouldn't be used case .list: return 32 case .hero: return 80 } } var iconSize: CGFloat { switch self { case .navigation, .message: return 10 // Intentionally not a multiple of 4 case .list: return 16 case .hero: return 24 } } } public enum ProfileIcon: Equatable, Hashable { case none case crown case rightPlus func iconVerticalInset(for size: Size) -> CGFloat { switch (self, size) { case (.crown, .navigation), (.crown, .message): return 1 case (.crown, .list): return 3 case (.crown, .hero): return 5 case (.rightPlus, _): return 3 default: return 0 } } } public var size: Size { didSet { widthConstraint.constant = (customWidth ?? size.viewSize) heightConstraint.constant = size.viewSize profileIconBackgroundWidthConstraint.constant = size.iconSize profileIconBackgroundHeightConstraint.constant = size.iconSize additionalProfileIconBackgroundWidthConstraint.constant = size.iconSize additionalProfileIconBackgroundHeightConstraint.constant = size.iconSize profileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) additionalProfileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) } } public var customWidth: CGFloat? { didSet { self.widthConstraint.constant = (customWidth ?? self.size.viewSize) } } override public var clipsToBounds: Bool { didSet { imageContainerView.clipsToBounds = clipsToBounds additionalImageContainerView.clipsToBounds = clipsToBounds imageContainerView.layer.cornerRadius = (clipsToBounds ? (additionalImageContainerView.isHidden ? (size.imageSize / 2) : (size.multiImageSize / 2)) : 0 ) imageContainerView.layer.cornerRadius = (clipsToBounds ? (size.multiImageSize / 2) : 0) } } public override var isHidden: Bool { didSet { widthConstraint.constant = (isHidden ? 0 : size.viewSize) heightConstraint.constant = (isHidden ? 0 : size.viewSize) } } // MARK: - Constraints private var widthConstraint: NSLayoutConstraint! private var heightConstraint: NSLayoutConstraint! private var imageViewTopConstraint: NSLayoutConstraint! private var imageViewLeadingConstraint: NSLayoutConstraint! private var imageViewCenterXConstraint: NSLayoutConstraint! private var imageViewCenterYConstraint: NSLayoutConstraint! private var imageViewWidthConstraint: NSLayoutConstraint! private var imageViewHeightConstraint: NSLayoutConstraint! private var additionalImageViewWidthConstraint: NSLayoutConstraint! private var additionalImageViewHeightConstraint: NSLayoutConstraint! private var profileIconTopConstraint: NSLayoutConstraint! private var profileIconBottomConstraint: NSLayoutConstraint! private var profileIconBackgroundLeftAlignConstraint: NSLayoutConstraint! private var profileIconBackgroundRightAlignConstraint: NSLayoutConstraint! private var profileIconBackgroundWidthConstraint: NSLayoutConstraint! private var profileIconBackgroundHeightConstraint: NSLayoutConstraint! private var additionalProfileIconTopConstraint: NSLayoutConstraint! private var additionalProfileIconBottomConstraint: NSLayoutConstraint! private var additionalProfileIconBackgroundLeftAlignConstraint: NSLayoutConstraint! private var additionalProfileIconBackgroundRightAlignConstraint: NSLayoutConstraint! private var additionalProfileIconBackgroundWidthConstraint: NSLayoutConstraint! private var additionalProfileIconBackgroundHeightConstraint: NSLayoutConstraint! private lazy var imageEdgeConstraints: [NSLayoutConstraint] = [ // MUST be in 'top, left, bottom, right' order imageView.pin(.top, to: .top, of: imageContainerView, withInset: 0), imageView.pin(.left, to: .left, of: imageContainerView, withInset: 0), imageView.pin(.bottom, to: .bottom, of: imageContainerView, withInset: 0), imageView.pin(.right, to: .right, of: imageContainerView, withInset: 0), animatedImageView.pin(.top, to: .top, of: imageContainerView, withInset: 0), animatedImageView.pin(.left, to: .left, of: imageContainerView, withInset: 0), animatedImageView.pin(.bottom, to: .bottom, of: imageContainerView, withInset: 0), animatedImageView.pin(.right, to: .right, of: imageContainerView, withInset: 0) ] private lazy var additionalImageEdgeConstraints: [NSLayoutConstraint] = [ // MUST be in 'top, left, bottom, right' order additionalImageView.pin(.top, to: .top, of: additionalImageContainerView, withInset: 0), additionalImageView.pin(.left, to: .left, of: additionalImageContainerView, withInset: 0), additionalImageView.pin(.bottom, to: .bottom, of: additionalImageContainerView, withInset: 0), additionalImageView.pin(.right, to: .right, of: additionalImageContainerView, withInset: 0), additionalAnimatedImageView.pin(.top, to: .top, of: additionalImageContainerView, withInset: 0), additionalAnimatedImageView.pin(.left, to: .left, of: additionalImageContainerView, withInset: 0), additionalAnimatedImageView.pin(.bottom, to: .bottom, of: additionalImageContainerView, withInset: 0), additionalAnimatedImageView.pin(.right, to: .right, of: additionalImageContainerView, withInset: 0) ] // MARK: - Components private lazy var imageContainerView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true result.themeBackgroundColor = .backgroundSecondary return result }() private lazy var imageView: UIImageView = { let result: UIImageView = UIImageView() result.translatesAutoresizingMaskIntoConstraints = false result.contentMode = .scaleAspectFill result.isHidden = true return result }() private lazy var animatedImageView: YYAnimatedImageView = { let result: YYAnimatedImageView = YYAnimatedImageView() result.translatesAutoresizingMaskIntoConstraints = false result.contentMode = .scaleAspectFill result.isHidden = true return result }() private lazy var additionalImageContainerView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true result.themeBackgroundColor = .primary result.themeBorderColor = .backgroundPrimary result.layer.borderWidth = 1 result.isHidden = true return result }() private lazy var additionalImageView: UIImageView = { let result: UIImageView = UIImageView() result.translatesAutoresizingMaskIntoConstraints = false result.contentMode = .scaleAspectFill result.themeTintColor = .textPrimary result.isHidden = true return result }() private lazy var additionalAnimatedImageView: YYAnimatedImageView = { let result: YYAnimatedImageView = YYAnimatedImageView() result.translatesAutoresizingMaskIntoConstraints = false result.contentMode = .scaleAspectFill result.isHidden = true return result }() private lazy var profileIconBackgroundView: UIView = { let result: UIView = UIView() result.isHidden = true return result }() private lazy var profileIconImageView: UIImageView = { let result: UIImageView = UIImageView() result.contentMode = .scaleAspectFit return result }() private lazy var additionalProfileIconBackgroundView: UIView = { let result: UIView = UIView() result.isHidden = true return result }() private lazy var additionalProfileIconImageView: UIImageView = { let result: UIImageView = UIImageView() result.contentMode = .scaleAspectFit return result }() // MARK: - Lifecycle public init(size: Size) { self.size = size super.init(frame: CGRect(x: 0, y: 0, width: size.viewSize, height: size.viewSize)) clipsToBounds = true setUpViewHierarchy() } public required init?(coder: NSCoder) { preconditionFailure("Use init(size:) instead.") } private func setUpViewHierarchy() { addSubview(imageContainerView) addSubview(profileIconBackgroundView) addSubview(additionalImageContainerView) addSubview(additionalProfileIconBackgroundView) profileIconBackgroundView.addSubview(profileIconImageView) additionalProfileIconBackgroundView.addSubview(additionalProfileIconImageView) widthConstraint = self.set(.width, to: self.size.viewSize) heightConstraint = self.set(.height, to: self.size.viewSize) imageViewTopConstraint = imageContainerView.pin(.top, to: .top, of: self) imageViewLeadingConstraint = imageContainerView.pin(.leading, to: .leading, of: self) imageViewCenterXConstraint = imageContainerView.center(.horizontal, in: self) imageViewCenterXConstraint.isActive = false imageViewCenterYConstraint = imageContainerView.center(.vertical, in: self) imageViewCenterYConstraint.isActive = false imageViewWidthConstraint = imageContainerView.set(.width, to: size.imageSize) imageViewHeightConstraint = imageContainerView.set(.height, to: size.imageSize) additionalImageContainerView.pin(.trailing, to: .trailing, of: self) additionalImageContainerView.pin(.bottom, to: .bottom, of: self) additionalImageViewWidthConstraint = additionalImageContainerView.set(.width, to: size.multiImageSize) additionalImageViewHeightConstraint = additionalImageContainerView.set(.height, to: size.multiImageSize) imageContainerView.addSubview(imageView) imageContainerView.addSubview(animatedImageView) additionalImageContainerView.addSubview(additionalImageView) additionalImageContainerView.addSubview(additionalAnimatedImageView) // Activate the image edge constraints imageEdgeConstraints.forEach { $0.isActive = true } additionalImageEdgeConstraints.forEach { $0.isActive = true } profileIconTopConstraint = profileIconImageView.pin( .top, to: .top, of: profileIconBackgroundView, withInset: 0 ) profileIconImageView.pin(.left, to: .left, of: profileIconBackgroundView) profileIconImageView.pin(.right, to: .right, of: profileIconBackgroundView) profileIconBottomConstraint = profileIconImageView.pin( .bottom, to: .bottom, of: profileIconBackgroundView, withInset: 0 ) profileIconBackgroundLeftAlignConstraint = profileIconBackgroundView.pin(.leading, to: .leading, of: imageContainerView) profileIconBackgroundRightAlignConstraint = profileIconBackgroundView.pin(.trailing, to: .trailing, of: imageContainerView) profileIconBackgroundView.pin(.bottom, to: .bottom, of: imageContainerView) profileIconBackgroundWidthConstraint = profileIconBackgroundView.set(.width, to: size.iconSize) profileIconBackgroundHeightConstraint = profileIconBackgroundView.set(.height, to: size.iconSize) profileIconBackgroundLeftAlignConstraint.isActive = false profileIconBackgroundRightAlignConstraint.isActive = false additionalProfileIconTopConstraint = additionalProfileIconImageView.pin( .top, to: .top, of: additionalProfileIconBackgroundView, withInset: 0 ) additionalProfileIconImageView.pin(.left, to: .left, of: additionalProfileIconBackgroundView) additionalProfileIconImageView.pin(.right, to: .right, of: additionalProfileIconBackgroundView) additionalProfileIconBottomConstraint = additionalProfileIconImageView.pin( .bottom, to: .bottom, of: additionalProfileIconBackgroundView, withInset: 0 ) additionalProfileIconBackgroundLeftAlignConstraint = additionalProfileIconBackgroundView.pin(.leading, to: .leading, of: additionalImageContainerView) additionalProfileIconBackgroundRightAlignConstraint = additionalProfileIconBackgroundView.pin(.trailing, to: .trailing, of: additionalImageContainerView) additionalProfileIconBackgroundView.pin(.bottom, to: .bottom, of: additionalImageContainerView) additionalProfileIconBackgroundWidthConstraint = additionalProfileIconBackgroundView.set(.width, to: size.iconSize) additionalProfileIconBackgroundHeightConstraint = additionalProfileIconBackgroundView.set(.height, to: size.iconSize) additionalProfileIconBackgroundLeftAlignConstraint.isActive = false additionalProfileIconBackgroundRightAlignConstraint.isActive = false } // MARK: - Content private func updateIconView( icon: ProfileIcon, imageView: UIImageView, backgroundView: UIView, topConstraint: NSLayoutConstraint, leftAlignConstraint: NSLayoutConstraint, rightAlignConstraint: NSLayoutConstraint, bottomConstraint: NSLayoutConstraint ) { backgroundView.isHidden = (icon == .none) leftAlignConstraint.isActive = ( icon == .none || icon == .crown ) rightAlignConstraint.isActive = ( icon == .rightPlus ) topConstraint.constant = icon.iconVerticalInset(for: size) bottomConstraint.constant = -icon.iconVerticalInset(for: size) switch icon { case .none: imageView.image = nil case .crown: imageView.image = UIImage(systemName: "crown.fill") backgroundView.themeBackgroundColor = .profileIcon_background ThemeManager.onThemeChange(observer: imageView) { [weak imageView] _, primaryColor in let targetColor: ThemeValue = (primaryColor == .green ? .profileIcon_greenPrimaryColor : .profileIcon ) guard imageView?.themeTintColor != targetColor else { return } imageView?.themeTintColor = targetColor } case .rightPlus: imageView.image = UIImage( systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold) ) imageView.themeTintColor = .black backgroundView.themeBackgroundColor = .primary } } // MARK: - Content private func prepareForReuse() { imageView.contentMode = .scaleAspectFill imageView.isHidden = true animatedImageView.contentMode = .scaleAspectFill animatedImageView.isHidden = true imageContainerView.clipsToBounds = clipsToBounds imageContainerView.themeBackgroundColor = .backgroundSecondary additionalImageContainerView.isHidden = true animatedImageView.image = nil additionalImageView.image = nil additionalAnimatedImageView.image = nil additionalImageView.isHidden = true additionalAnimatedImageView.isHidden = true additionalImageContainerView.clipsToBounds = clipsToBounds imageViewTopConstraint.isActive = false imageViewLeadingConstraint.isActive = false imageViewCenterXConstraint.isActive = true imageViewCenterYConstraint.isActive = true profileIconBackgroundView.isHidden = true profileIconBackgroundLeftAlignConstraint.isActive = false profileIconBackgroundRightAlignConstraint.isActive = false additionalProfileIconBackgroundView.isHidden = true additionalProfileIconBackgroundLeftAlignConstraint.isActive = false additionalProfileIconBackgroundRightAlignConstraint.isActive = false imageEdgeConstraints.forEach { $0.constant = 0 } additionalImageEdgeConstraints.forEach { $0.constant = 0 } } public func update( _ info: Info, additionalInfo: Info? = nil ) { prepareForReuse() // Sort out the icon first updateIconView( icon: info.icon, imageView: profileIconImageView, backgroundView: profileIconBackgroundView, topConstraint: profileIconTopConstraint, leftAlignConstraint: profileIconBackgroundLeftAlignConstraint, rightAlignConstraint: profileIconBackgroundRightAlignConstraint, bottomConstraint: profileIconBottomConstraint ) // Populate the main imageView switch info.imageData?.guessedImageFormat { case .gif, .webp: animatedImageView.image = info.imageData.map { YYImage(data: $0) } default: imageView.image = info.imageData .map { guard info.renderingMode != .automatic else { return UIImage(data: $0) } return UIImage(data: $0)?.withRenderingMode(info.renderingMode) } } imageView.themeTintColor = info.themeTintColor imageView.isHidden = (imageView.image == nil) animatedImageView.themeTintColor = info.themeTintColor animatedImageView.isHidden = (animatedImageView.image == nil) imageContainerView.themeBackgroundColor = info.backgroundColor imageContainerView.themeBackgroundColorForced = info.forcedBackgroundColor profileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) imageEdgeConstraints.enumerated().forEach { index, constraint in switch index % 4 { case 0: constraint.constant = info.inset.top case 1: constraint.constant = info.inset.left case 2: constraint.constant = -info.inset.bottom case 3: constraint.constant = -info.inset.right default: break } } // Check if there is a second image (if not then set the size and finish) guard let additionalInfo: Info = additionalInfo else { imageViewWidthConstraint.constant = size.imageSize imageViewHeightConstraint.constant = size.imageSize imageContainerView.layer.cornerRadius = (imageContainerView.clipsToBounds ? (size.imageSize / 2) : 0) return } // Sort out the additional icon first updateIconView( icon: additionalInfo.icon, imageView: additionalProfileIconImageView, backgroundView: additionalProfileIconBackgroundView, topConstraint: additionalProfileIconTopConstraint, leftAlignConstraint: additionalProfileIconBackgroundLeftAlignConstraint, rightAlignConstraint: additionalProfileIconBackgroundRightAlignConstraint, bottomConstraint: additionalProfileIconBottomConstraint ) // Set the additional image content and reposition the image views correctly switch additionalInfo.imageData?.guessedImageFormat { case .gif, .webp: additionalAnimatedImageView.image = additionalInfo.imageData.map { YYImage(data: $0) } default: additionalImageView.image = additionalInfo.imageData .map { guard additionalInfo.renderingMode != .automatic else { return UIImage(data: $0) } return UIImage(data: $0)?.withRenderingMode(additionalInfo.renderingMode) } } additionalImageView.themeTintColor = additionalInfo.themeTintColor additionalImageView.isHidden = (additionalImageView.image == nil) additionalAnimatedImageView.themeTintColor = additionalInfo.themeTintColor additionalAnimatedImageView.isHidden = (additionalAnimatedImageView.image == nil) additionalImageContainerView.isHidden = false switch (info.backgroundColor, info.forcedBackgroundColor) { case (_, .some(let color)): additionalImageContainerView.themeBackgroundColorForced = color case (.some(let color), _): additionalImageContainerView.themeBackgroundColor = color default: additionalImageContainerView.themeBackgroundColor = .primary } additionalImageEdgeConstraints.enumerated().forEach { index, constraint in switch index % 4 { case 0: constraint.constant = additionalInfo.inset.top case 1: constraint.constant = additionalInfo.inset.left case 2: constraint.constant = -additionalInfo.inset.bottom case 3: constraint.constant = -additionalInfo.inset.right default: break } } imageViewTopConstraint.isActive = true imageViewLeadingConstraint.isActive = true imageViewCenterXConstraint.isActive = false imageViewCenterYConstraint.isActive = false imageViewWidthConstraint.constant = size.multiImageSize imageViewHeightConstraint.constant = size.multiImageSize imageContainerView.layer.cornerRadius = (imageContainerView.clipsToBounds ? (size.multiImageSize / 2) : 0) additionalImageViewWidthConstraint.constant = size.multiImageSize additionalImageViewHeightConstraint.constant = size.multiImageSize additionalImageContainerView.layer.cornerRadius = (additionalImageContainerView.clipsToBounds ? (size.multiImageSize / 2) : 0 ) additionalProfileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) } } import SwiftUI public struct ProfilePictureSwiftUI: UIViewRepresentable { public typealias UIViewType = ProfilePictureView var size: ProfilePictureView.Size var info: ProfilePictureView.Info var additionalInfo: ProfilePictureView.Info? public init( size: ProfilePictureView.Size, info: ProfilePictureView.Info, additionalInfo: ProfilePictureView.Info? = nil ) { self.size = size self.info = info self.additionalInfo = additionalInfo } public func makeUIView(context: Context) -> ProfilePictureView { ProfilePictureView(size: size) } public func updateUIView(_ profilePictureView: ProfilePictureView, context: Context) { profilePictureView.update( info, additionalInfo: additionalInfo ) } }