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
392 lines
18 KiB
Swift
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
|
|
}
|
|
}
|