deep-linking-sample/Apps/Wikipedia/WMF Framework/ExploreCardCollectionViewCell.swift
Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00

392 lines
18 KiB
Swift

import UIKit
public protocol CardContent {
var view: UIView! { get }
func contentHeight(forWidth: CGFloat) -> CGFloat
}
// Allows the card background view to communicate with the cell to detect taps in the title area
// A Random article card navigates to different destinations depending on whether the area
// above or below the card content is tapped.
fileprivate protocol CardBackgroundViewDelegate: UIView {
func titleAreaYThreshold(for cardBackgroundView: CardBackgroundView) -> CGFloat // Return value in the coordinate system of the card background view
var titleAreaTapped: Bool { get set }
}
private class CardBackgroundView: UIView {
fileprivate weak var delegate: CardBackgroundViewDelegate?
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let delegate = delegate {
let yThreshold = delegate.titleAreaYThreshold(for: self)
delegate.titleAreaTapped = point.y < yThreshold
}
return super.hitTest(point, with: event)
}
}
public protocol ExploreCardCollectionViewCellDelegate: AnyObject {
func exploreCardCollectionViewCellWantsCustomization(_ cell: ExploreCardCollectionViewCell)
func exploreCardCollectionViewCellWantsToUndoCustomization(_ cell: ExploreCardCollectionViewCell)
}
public class ExploreCardCollectionViewCell: CollectionViewCell, CardBackgroundViewDelegate, Themeable {
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
public let customizationButton = UIButton()
private let undoButton = UIButton()
private let undoLabel = UILabel()
private let footerButton = AlignedImageButton()
public weak var delegate: ExploreCardCollectionViewCellDelegate?
private let cardBackgroundView = CardBackgroundView()
private let cardCornerRadius = Theme.exploreCardCornerRadius
private let cardShadowRadius = CGFloat(10)
private let cardShadowOffset = CGSize(width: 0, height: 2)
public var titleAreaTapped: Bool = false
static let overflowImage = UIImage(named: "overflow")
public override func setup() {
super.setup()
titleLabel.numberOfLines = 0
contentView.addSubview(titleLabel)
subtitleLabel.numberOfLines = 0
contentView.addSubview(subtitleLabel)
customizationButton.setImage(ExploreCardCollectionViewCell.overflowImage, for: .normal)
customizationButton.contentEdgeInsets = .zero
customizationButton.imageEdgeInsets = .zero
customizationButton.titleEdgeInsets = .zero
customizationButton.titleLabel?.textAlignment = .center
customizationButton.addTarget(self, action: #selector(customizationButtonPressed), for: .touchUpInside)
cardBackgroundView.layer.cornerRadius = cardCornerRadius
cardBackgroundView.layer.shadowOffset = cardShadowOffset
cardBackgroundView.layer.shadowRadius = cardShadowRadius
cardBackgroundView.layer.shadowColor = cardShadowColor.cgColor
cardBackgroundView.layer.shadowOpacity = cardShadowOpacity
cardBackgroundView.layer.masksToBounds = false
cardBackgroundView.isOpaque = true
cardBackgroundView.delegate = self
contentView.addSubview(cardBackgroundView)
contentView.addSubview(customizationButton)
footerButton.imageIsRightAligned = true
let image = #imageLiteral(resourceName: "places-more").imageFlippedForRightToLeftLayoutDirection()
footerButton.setImage(image, for: .normal)
footerButton.isUserInteractionEnabled = false
footerButton.titleLabel?.numberOfLines = 0
footerButton.titleLabel?.textAlignment = .right
contentView.addSubview(footerButton)
undoLabel.numberOfLines = 0
contentView.addSubview(undoLabel)
undoButton.titleLabel?.numberOfLines = 0
undoButton.setTitle(CommonStrings.undo, for: .normal)
undoButton.addTarget(self, action: #selector(undoButtonPressed), for: .touchUpInside)
undoButton.isUserInteractionEnabled = true
undoButton.titleLabel?.textAlignment = .right
contentView.addSubview(undoButton)
}
// This method is called to reset the cell to the default configuration. It is called on initial setup and prepareForReuse. Subclassers should call super.
override open func reset() {
super.reset()
layoutMargins = UIEdgeInsets(top: 15, left: 13, bottom: 15, right: 13)
footerButton.isHidden = true
undoButton.isHidden = true
undoLabel.isHidden = true
}
public var cardContent: (CardContent & Themeable)? = nil {
didSet {
oldValue?.view?.removeFromSuperview()
guard let view = cardContent?.view else {
return
}
view.layer.cornerRadius = cardCornerRadius
contentView.addSubview(view)
}
}
fileprivate func titleAreaYThreshold(for cardBackgroundView: CardBackgroundView) -> CGFloat {
// The title area is defined to include card background from its top down to the bottom of the card content
// This registers taps on the side margins of the card content as in the title area
let yThreshold = cardContent?.view?.frame.maxY ?? 0.0
let convertedPoint = convert(CGPoint(x: 0.0, y: yThreshold), to: cardBackgroundView)
return convertedPoint.y
}
private var undoTitle: String? {
didSet {
undoLabel.text = undoTitle
}
}
public var footerTitle: String? {
get {
return footerButton.title(for: .normal)
}
set {
footerButton.setTitle(newValue, for: .normal)
footerButton.isHidden = newValue == nil
setNeedsLayout()
}
}
public var title: String? {
get {
return titleLabel.text
}
set {
titleLabel.text = newValue
setNeedsLayout()
}
}
public var subtitle: String? {
get {
return subtitleLabel.text
}
set {
subtitleLabel.text = newValue
setNeedsLayout()
}
}
public var isCustomizationButtonHidden: Bool {
get {
return customizationButton.isHidden
}
set {
customizationButton.isHidden = newValue
setNeedsLayout()
}
}
public var undoType: WMFContentGroupUndoType = .none {
didSet {
switch undoType {
case .contentGroup:
undoTitle = WMFLocalizedString("explore-feed-preferences-card-hidden-title", value: "Card hidden", comment: "Title for button that appears in place of feed card hidden by user via the overflow button")
isCollapsed = true
case .contentGroupKind:
guard let title = title else {
return
}
undoTitle = String.localizedStringWithFormat(WMFLocalizedString("explore-feed-preferences-feed-cards-hidden-title", value: "All %@ cards hidden", comment: "Title for cell that appears in place of feed card hidden by user via the overflow button - %@ is replaced with feed card type"), title)
isCollapsed = true
default:
isCollapsed = false
}
}
}
private var isCollapsed: Bool = false {
didSet {
if isCollapsed {
undoLabel.isHidden = false
customizationButton.isHidden = true
undoButton.isHidden = false
cardContent?.view.isHidden = true
titleLabel.isHidden = true
subtitleLabel.isHidden = true
footerButton.isHidden = true
UIAccessibility.post(notification: UIAccessibility.Notification.layoutChanged, argument: undoButton)
} else {
cardContent?.view.isHidden = false
undoLabel.isHidden = true
undoButton.isHidden = true
titleLabel.isHidden = title == nil
subtitleLabel.isHidden = subtitle == nil
footerButton.isHidden = footerTitle == nil
}
setNeedsLayout()
}
}
override public func sizeThatFits(_ size: CGSize, apply: Bool) -> CGSize {
let size = super.sizeThatFits(size, apply: apply) // intentionally shade size
var origin = CGPoint(x: layoutMargins.left, y: layoutMargins.top)
let widthMinusMargins = size.width - layoutMargins.left - layoutMargins.right
let isRTL = traitCollection.layoutDirection == .rightToLeft
let labelHorizontalAlignment: HorizontalAlignment = isRTL ? .right : .left
let buttonHorizontalAlignment: HorizontalAlignment = isRTL ? .left : .right
var customizationButtonDeltaWidthMinusMargins: CGFloat = 0
if !customizationButton.isHidden {
let customizationButtonSize = CGSize(width: 50, height: 50)
let customizationButtonNudgeWidth = round(0.55 * customizationButtonSize.width)
customizationButtonDeltaWidthMinusMargins = customizationButtonNudgeWidth
if apply {
let originX = isRTL ? layoutMargins.left - customizationButtonSize.width + customizationButtonNudgeWidth : size.width - layoutMargins.right - customizationButtonNudgeWidth
let originY = origin.y - round(0.25 * customizationButtonSize.height)
let customizationButtonOrigin = CGPoint(x: originX, y: originY)
customizationButton.frame = CGRect(origin: customizationButtonOrigin, size: customizationButtonSize)
}
}
var labelOrigin = origin
if isRTL {
labelOrigin.x += customizationButtonDeltaWidthMinusMargins
}
if !titleLabel.isHidden {
origin.y += titleLabel.wmf_preferredHeight(at: labelOrigin, maximumWidth: widthMinusMargins - customizationButtonDeltaWidthMinusMargins, horizontalAlignment: labelHorizontalAlignment, spacing: 4, apply: apply)
labelOrigin.y = origin.y
}
if !subtitleLabel.isHidden {
origin.y += subtitleLabel.wmf_preferredHeight(at: labelOrigin, maximumWidth: widthMinusMargins - customizationButtonDeltaWidthMinusMargins, horizontalAlignment: labelHorizontalAlignment, spacing: 20, apply: apply)
}
if let cardContent = cardContent, !cardContent.view.isHidden {
let view = cardContent.view
let height = cardContent.contentHeight(forWidth: widthMinusMargins)
let cardContentViewFrame = CGRect(origin: origin, size: CGSize(width: widthMinusMargins, height: height))
if apply {
view?.frame = cardContentViewFrame
cardBackgroundView.frame = cardContentViewFrame.insetBy(dx: -cardBorderWidth, dy: -cardBorderWidth)
}
origin.y += cardContentViewFrame.height
}
if isCollapsed, !undoLabel.isHidden, !undoButton.isHidden {
let undoOffset: UIOffset = UIOffset(horizontal: 15, vertical: 16)
labelOrigin.x += undoOffset.horizontal
labelOrigin.y += undoOffset.vertical
let undoButtonMaxWidthPercentage: CGFloat = 0.25
let undoLabelMaxWidth = widthMinusMargins - (widthMinusMargins * undoButtonMaxWidthPercentage)
let undoLabelMinWidth = widthMinusMargins * 0.5
let undoLabelX = isRTL ? widthMinusMargins - undoLabelMaxWidth : labelOrigin.x
let undoLabelFrameHeight = undoLabel.wmf_preferredHeight(at: CGPoint(x: undoLabelX, y: labelOrigin.y), maximumWidth: undoLabelMaxWidth, minimumWidth: undoLabelMinWidth, horizontalAlignment: labelHorizontalAlignment, spacing: 0, apply: apply)
let undoButtonMaxWidth = widthMinusMargins * undoButtonMaxWidthPercentage
let undoButtonX = isRTL ? labelOrigin.x : widthMinusMargins - undoButtonMaxWidth
let undoButtonMinSize = CGSize(width: UIView.noIntrinsicMetric, height: undoLabelFrameHeight)
let undoButtonMaxSize = CGSize(width: undoButtonMaxWidth, height: UIView.noIntrinsicMetric)
let undoButtonFrame = undoButton.wmf_preferredFrame(at: CGPoint(x: undoButtonX, y: labelOrigin.y), maximumSize: undoButtonMaxSize, minimumSize: undoButtonMinSize, horizontalAlignment: buttonHorizontalAlignment, apply: apply)
let undoHeight = max(undoLabelFrameHeight, undoButtonFrame.height)
// If cardBackgroundView metrics change double check the hitTest() override in CardBackgroundView
let cardBackgroundViewHeight = undoHeight + undoOffset.vertical * 2
let cardBackgroundViewFrame = CGRect(x: layoutMargins.left, y: layoutMargins.top, width: widthMinusMargins, height: cardBackgroundViewHeight)
if apply {
cardBackgroundView.frame = cardBackgroundViewFrame
}
origin.y += cardBackgroundViewFrame.height
}
if !footerButton.isHidden {
origin.y += layoutMargins.bottom
origin.y += footerButton.wmf_preferredHeight(at: origin, maximumWidth: widthMinusMargins, horizontalAlignment: buttonHorizontalAlignment, spacing: 0, apply: apply)
}
origin.y += layoutMargins.bottom
let totalSize = CGSize(width: size.width, height: ceil(origin.y))
if apply {
cardBackgroundView.layer.shadowPath = UIBezierPath(roundedRect: cardBackgroundView.bounds, cornerRadius: cardBackgroundView.layer.cornerRadius).cgPath
}
return totalSize
}
public override func updateFonts(with traitCollection: UITraitCollection) {
super.updateFonts(with: traitCollection)
titleLabel.font = UIFont.wmf_font(.semiboldSubheadline, compatibleWithTraitCollection: traitCollection)
subtitleLabel.font = UIFont.wmf_font(.subheadline, compatibleWithTraitCollection: traitCollection)
footerButton.titleLabel?.font = UIFont.wmf_font(.semiboldSubheadline, compatibleWithTraitCollection: traitCollection)
undoLabel.font = UIFont.wmf_font(.subheadline, compatibleWithTraitCollection: traitCollection)
undoButton.titleLabel?.font = UIFont.wmf_font(.semiboldSubheadline, compatibleWithTraitCollection: traitCollection)
customizationButton.titleLabel?.font = UIFont.wmf_font(.boldTitle1, compatibleWithTraitCollection: traitCollection)
}
private var cardShadowColor: UIColor = .black {
didSet {
cardBackgroundView.layer.shadowColor = cardShadowColor.cgColor
}
}
private var cardShadowOpacity: Float = 0 {
didSet {
guard cardBackgroundView.layer.shadowOpacity != cardShadowOpacity else {
return
}
cardBackgroundView.layer.shadowOpacity = cardShadowOpacity
}
}
private var cardBorderWidth: CGFloat = 1 {
didSet {
cardBackgroundView.layer.borderWidth = cardBorderWidth
}
}
public override func updateBackgroundColorOfLabels() {
super.updateBackgroundColorOfLabels()
titleLabel.backgroundColor = labelBackgroundColor
subtitleLabel.backgroundColor = labelBackgroundColor
footerButton.backgroundColor = labelBackgroundColor
undoLabel.backgroundColor = labelBackgroundColor
undoButton.backgroundColor = labelBackgroundColor
customizationButton.backgroundColor = labelBackgroundColor
}
public func apply(theme: Theme) {
contentView.tintColor = theme.colors.link
let backgroundColor = isCollapsed ? theme.colors.cardButtonBackground : theme.colors.paperBackground
let selectedBackgroundColor = isCollapsed ? theme.colors.cardButtonBackground : theme.colors.midBackground
let cardBackgroundViewBorderColor = isCollapsed ? backgroundColor.cgColor : theme.colors.cardBorder.cgColor
cardBackgroundView.layer.borderColor = cardBackgroundViewBorderColor
setBackgroundColors(.clear, selected: selectedBackgroundColor)
titleLabel.textColor = theme.colors.primaryText
subtitleLabel.textColor = theme.colors.secondaryText
customizationButton.setTitleColor(theme.colors.link, for: .normal)
footerButton.setTitleColor(theme.colors.link, for: .normal)
undoLabel.textColor = theme.colors.primaryText
undoButton.setTitleColor(theme.colors.link, for: .normal)
updateSelectedOrHighlighted()
cardBackgroundView.backgroundColor = backgroundColor
cardShadowOpacity = theme.cardShadowOpacity
cardShadowColor = theme.colors.cardShadow
cardContent?.apply(theme: theme)
let displayScale = max(1, traitCollection.displayScale)
cardBorderWidth = CGFloat(theme.cardBorderWidthInPixels) / displayScale
}
@objc func customizationButtonPressed() {
delegate?.exploreCardCollectionViewCellWantsCustomization(self)
}
@objc func undoButtonPressed() {
delegate?.exploreCardCollectionViewCellWantsToUndoCustomization(self)
}
// MARK: - Accessibility
override open func updateAccessibilityElements() {
var updatedAccessibilityElements: [Any] = []
if isCollapsed {
updatedAccessibilityElements.append(undoLabel)
updatedAccessibilityElements.append(undoButton)
} else {
let groupedLabels = [titleLabel, subtitleLabel]
let customizeActionTitle = WMFLocalizedString("explore-feed-customize-accessibility-title", value: "Customize", comment: "Accessibility title for feed customization")
let customizeAction = UIAccessibilityCustomAction(name: customizeActionTitle, target: self, selector: #selector(customizationButtonPressed))
updatedAccessibilityElements.append(LabelGroupAccessibilityElement(view: self, labels: groupedLabels, actions: [customizeAction]))
if let contentView = cardContent?.view {
updatedAccessibilityElements.append(contentView)
}
if !footerButton.isHidden, let label = footerButton.titleLabel {
let footerElement = UIAccessibilityElement(accessibilityContainer: self)
footerElement.isAccessibilityElement = true
footerElement.accessibilityLabel = label.text
footerElement.accessibilityTraits = UIAccessibilityTraits.link
footerElement.accessibilityFrameInContainerSpace = footerButton.frame
updatedAccessibilityElements.append(footerElement)
}
}
accessibilityElements = updatedAccessibilityElements
}
}