From dde3a4d2bd701b5176d1bd1b6971f9673759b86d Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Wed, 26 Jun 2024 17:46:33 +0200 Subject: [PATCH 1/3] [Popover#903] Added UIKit Popover helpers --- .../Popover/Model/PopoverColors.swift | 29 +++ .../Popover/Model/PopoverIntent.swift | 39 ++++ .../Popover/Model/PopoverIntentTests.swift | 48 ++++ .../Popover/Model/PopoverSpaces.swift | 14 ++ .../PopoverBackgroundConfiguration.swift | 15 ++ .../Popover/UIKit/PopoverBackgroundView.swift | 211 ++++++++++++++++++ .../Popover/UIKit/PopoverViewController.swift | 99 ++++++++ .../UIViewController-presentPopover.swift | 39 ++++ .../GetColors/PopoverGetColorsUseCase.swift | 71 ++++++ .../PopoverGetColorsUseCaseTests.swift | 106 +++++++++ .../GetSpaces/PopoverGetSpacesUseCase.swift | 20 ++ .../PopoverGetSpacesUseCaseTests.swift | 26 +++ .../Popover/ViewModel/PopoverViewModel.swift | 29 +++ .../ViewModel/PopoverViewModelTests.swift | 69 ++++++ spark/Demo/Classes/Enum/UIComponent.swift | 2 + .../Components/ComponentsViewController.swift | 2 + .../PopoverContentDemoViewController.swift | 49 ++++ .../PopoverPresentingUIViewController.swift | 46 ++++ 18 files changed, 914 insertions(+) create mode 100644 core/Sources/Components/Popover/Model/PopoverColors.swift create mode 100644 core/Sources/Components/Popover/Model/PopoverIntent.swift create mode 100644 core/Sources/Components/Popover/Model/PopoverIntentTests.swift create mode 100644 core/Sources/Components/Popover/Model/PopoverSpaces.swift create mode 100644 core/Sources/Components/Popover/UIKit/PopoverBackgroundConfiguration.swift create mode 100644 core/Sources/Components/Popover/UIKit/PopoverBackgroundView.swift create mode 100644 core/Sources/Components/Popover/UIKit/PopoverViewController.swift create mode 100644 core/Sources/Components/Popover/UIKit/UIViewController-presentPopover.swift create mode 100644 core/Sources/Components/Popover/UseCase/GetColors/PopoverGetColorsUseCase.swift create mode 100644 core/Sources/Components/Popover/UseCase/GetColors/PopoverGetColorsUseCaseTests.swift create mode 100644 core/Sources/Components/Popover/UseCase/GetSpaces/PopoverGetSpacesUseCase.swift create mode 100644 core/Sources/Components/Popover/UseCase/GetSpaces/PopoverGetSpacesUseCaseTests.swift create mode 100644 core/Sources/Components/Popover/ViewModel/PopoverViewModel.swift create mode 100644 core/Sources/Components/Popover/ViewModel/PopoverViewModelTests.swift create mode 100644 spark/Demo/Classes/View/Components/Popover/UIKit/PopoverContentDemoViewController.swift create mode 100644 spark/Demo/Classes/View/Components/Popover/UIKit/PopoverPresentingUIViewController.swift diff --git a/core/Sources/Components/Popover/Model/PopoverColors.swift b/core/Sources/Components/Popover/Model/PopoverColors.swift new file mode 100644 index 000000000..53407b2be --- /dev/null +++ b/core/Sources/Components/Popover/Model/PopoverColors.swift @@ -0,0 +1,29 @@ +// +// PopoverColors.swift +// Spark +// +// Created by louis.borlee on 25/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +public struct PopoverColors { + + /// Popover background color + public let background: any ColorToken + /// Popover foreground color + public let foreground: any ColorToken + + /// PopoverColors init + /// - Parameters: + /// - background: Popover background color + /// - foreground: Popover foreground color + public init( + background: any ColorToken, + foreground: any ColorToken + ) { + self.background = background + self.foreground = foreground + } +} diff --git a/core/Sources/Components/Popover/Model/PopoverIntent.swift b/core/Sources/Components/Popover/Model/PopoverIntent.swift new file mode 100644 index 000000000..e1dee4141 --- /dev/null +++ b/core/Sources/Components/Popover/Model/PopoverIntent.swift @@ -0,0 +1,39 @@ +// +// PopoverIntent.swift +// Spark +// +// Created by louis.borlee on 25/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +/// Intent used to { get set } background & foreground colors on the popover +public enum PopoverIntent: CaseIterable { + case surface + case main + case support + case accent + case basic + case success + case alert + case error + case info + case neutral + + internal var getColorsUseCase: PopoverGetColorsUseCasable { + return PopoverGetColorsUseCase() + } + + internal func getColors(theme: Theme, getColorsUseCase: PopoverGetColorsUseCasable) -> PopoverColors { + return getColorsUseCase.execute(colors: theme.colors, intent: self) + } + + /// Get the colors to apply on popovers from an intent + /// - Parameters: + /// - theme: Spark theme + /// - Returns: PopoverColors + public func getColors(theme: Theme) -> PopoverColors { + return self.getColors(theme: theme, getColorsUseCase: self.getColorsUseCase) + } +} diff --git a/core/Sources/Components/Popover/Model/PopoverIntentTests.swift b/core/Sources/Components/Popover/Model/PopoverIntentTests.swift new file mode 100644 index 000000000..e1d103a37 --- /dev/null +++ b/core/Sources/Components/Popover/Model/PopoverIntentTests.swift @@ -0,0 +1,48 @@ +// +// PopoverIntentTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 26/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +@testable import SparkCore + +final class PopoverIntentTests: XCTestCase { + + private let theme = ThemeGeneratedMock.mocked() + + func test_internal_getColors() throws { + // GIVEN + let useCaseMock = PopoverGetColorsUseCasableGeneratedMock() + useCaseMock.executeWithColorsAndIntentReturnValue = .init( + background: self.theme.colors.feedback.alert, + foreground: self.theme.colors.main.onMain + ) + + // WHEN + let colors = PopoverIntent.alert.getColors(theme: self.theme, getColorsUseCase: useCaseMock) + + // THEN - Values + XCTAssertTrue(colors.background.equals(self.theme.colors.feedback.alert), "Wrong background color") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.main.onMain), "Wrong foreground color") + + // THEN - UseCase + XCTAssertEqual(useCaseMock.executeWithColorsAndIntentCallsCount, 1, "useCaseMock.executeWithColorsAndIntent should have been called once") + let receivedArguments = try XCTUnwrap(useCaseMock.executeWithColorsAndIntentReceivedArguments) + XCTAssertIdentical( + receivedArguments.colors as? ColorsGeneratedMock, + self.theme.colors as? ColorsGeneratedMock, + "Wrong receivedArguments.colors" + ) + XCTAssertEqual(receivedArguments.intent, .alert, "Wrong receivedArguments.intent") + } + + func test_used_getColorsUseCase() { + for intent in PopoverIntent.allCases { + XCTAssertTrue(intent.getColorsUseCase is PopoverGetColorsUseCase, "Wrong getColorsUseCase type for intent \(intent)") + } + } + +} diff --git a/core/Sources/Components/Popover/Model/PopoverSpaces.swift b/core/Sources/Components/Popover/Model/PopoverSpaces.swift new file mode 100644 index 000000000..8183af6a1 --- /dev/null +++ b/core/Sources/Components/Popover/Model/PopoverSpaces.swift @@ -0,0 +1,14 @@ +// +// PopoverSpaces.swift +// SparkCore +// +// Created by louis.borlee on 25/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +struct PopoverSpaces { + let horizontal: CGFloat + let vertical: CGFloat +} diff --git a/core/Sources/Components/Popover/UIKit/PopoverBackgroundConfiguration.swift b/core/Sources/Components/Popover/UIKit/PopoverBackgroundConfiguration.swift new file mode 100644 index 000000000..e853ce4a2 --- /dev/null +++ b/core/Sources/Components/Popover/UIKit/PopoverBackgroundConfiguration.swift @@ -0,0 +1,15 @@ +// +// PopoverBackgroundConfiguration.swift +// SparkCore +// +// Created by louis.borlee on 26/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit + +final class PopoverBackgroundConfiguration { + static var arrowSize = CGFloat.zero + static var backgroundColor = UIColor.clear + static var showArrow = true +} diff --git a/core/Sources/Components/Popover/UIKit/PopoverBackgroundView.swift b/core/Sources/Components/Popover/UIKit/PopoverBackgroundView.swift new file mode 100644 index 000000000..bd507bbf0 --- /dev/null +++ b/core/Sources/Components/Popover/UIKit/PopoverBackgroundView.swift @@ -0,0 +1,211 @@ +// +// PopoverBackgroundView.swift +// SparkCore +// +// Created by louis.borlee on 26/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit + +final class PopoverBackgroundView: UIPopoverBackgroundView { + + override class func contentViewInsets() -> UIEdgeInsets { + return .zero + } + + override class func arrowHeight() -> CGFloat { + return PopoverBackgroundConfiguration.arrowSize + } + + private var direction: UIPopoverArrowDirection = .any + override var arrowDirection: UIPopoverArrowDirection { + get { return self.direction } + set { + guard newValue != self.direction else { return } + self.direction = newValue + self.setNeedsLayout() + } + } + + private var offset: CGFloat = .zero + override var arrowOffset: CGFloat { + get { return self.offset } + set { + guard newValue != self.offset else { return } + self.offset = newValue + self.setNeedsLayout() + } + } + + private var leadingConstraint: NSLayoutConstraint = .init() + private var trailingConstraint: NSLayoutConstraint = .init() + private var topConstraint: NSLayoutConstraint = .init() + private var bottomConstraint: NSLayoutConstraint = .init() + + private var previouslyModifiedConstraint: NSLayoutConstraint? + + private var arrowShape: CAShapeLayer? + + private let cornerRadius = 8.0 + private let spacing = 0.0 + + override init(frame: CGRect) { + super.init(frame: .zero) + + self.backgroundColor = .clear + + let backgroundView = self.createBackgroundView() + self.addSubview(backgroundView) + + self.leadingConstraint = backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor) + self.trailingConstraint = backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor) + self.topConstraint = backgroundView.topAnchor.constraint(equalTo: self.topAnchor) + self.bottomConstraint = backgroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor) + NSLayoutConstraint.activate([ + self.leadingConstraint, + self.trailingConstraint, + self.topConstraint, + self.bottomConstraint + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + let arrowHeight = Self.arrowHeight() + self.previouslyModifiedConstraint?.constant = 0 + var path: CGPath? + let shouldDrawArrow = PopoverBackgroundConfiguration.showArrow + switch self.arrowDirection { + case .left: + self.leadingConstraint.constant = arrowHeight + self.spacing + self.previouslyModifiedConstraint = self.leadingConstraint + if PopoverBackgroundConfiguration.arrowSize > 0 { + path = self.getLeftArrowPath() + } + case .right: + self.trailingConstraint.constant = -(arrowHeight + self.spacing) + self.previouslyModifiedConstraint = self.trailingConstraint + if shouldDrawArrow { + path = self.getRightArrowPath() + } + case .up: + self.topConstraint.constant = arrowHeight + self.spacing + self.previouslyModifiedConstraint = self.topConstraint + if shouldDrawArrow { + path = self.getUpArrowPath() + } + case.down: + self.bottomConstraint.constant = -(arrowHeight + self.spacing) + self.previouslyModifiedConstraint = self.bottomConstraint + if shouldDrawArrow { + path = self.getDownArrowPath() + } + default: + self.previouslyModifiedConstraint = nil + break + } + + self.arrowShape?.removeFromSuperlayer() + if let path { + let shape = CAShapeLayer() + shape.path = path + shape.fillColor = PopoverBackgroundConfiguration.backgroundColor.cgColor + self.layer.insertSublayer(shape, at: 0) + self.arrowShape = shape + } + } + + // MARK: - Background Image + private func createBackgroundView() -> UIView { + let view = UIView(frame: .init(origin: .zero, size: .zero)) + view.layer.cornerRadius = self.cornerRadius + view.backgroundColor = PopoverBackgroundConfiguration.backgroundColor + view.isOpaque = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + // MARK: - Arrow + /// Used for getting left and right arrow tip y position taking cornerRadius into account + private func getTipY(arrowHeight: CGFloat) -> CGFloat { + let estimatedTipY = (self.frame.height / 2) + self.arrowOffset + let maxTipY = self.frame.height - self.cornerRadius - arrowHeight + let minTipY = self.cornerRadius + arrowHeight + + // Bounded value between min and max tip y + let tipY = max(minTipY, min(estimatedTipY, maxTipY)) + return tipY + } + + private func getLeftArrowPath() -> CGPath { + let arrowHeight = Self.arrowHeight() + + let tipY = self.getTipY(arrowHeight: arrowHeight) + + let tip = CGPoint(x: 0, y: tipY) + let topRightCorner = tip.applying(.init(translationX: arrowHeight, y: -arrowHeight)) + let bottomRightCorner = tip.applying(.init(translationX: arrowHeight, y: arrowHeight)) + + return self.getTrianglePath(point1: tip, point2: topRightCorner, point3: bottomRightCorner) + } + + private func getRightArrowPath() -> CGPath { + let arrowHeight = Self.arrowHeight() + + let tipY = self.getTipY(arrowHeight: arrowHeight) + + let tip = CGPoint(x: self.frame.width, y: tipY) + let topLeftCorner = tip.applying(.init(translationX: -arrowHeight, y: -arrowHeight)) + let bottomLeftCorner = tip.applying(.init(translationX: -arrowHeight, y: arrowHeight)) + + return self.getTrianglePath(point1: tip, point2: topLeftCorner, point3: bottomLeftCorner) + } + + /// Used for getting up and down arrow tip x position taking cornerRadius into account + private func getTipX(arrowHeight: CGFloat) -> CGFloat { + let estimatedTipX = (self.frame.width / 2) + self.arrowOffset + let maxTipX = self.frame.width - self.cornerRadius - arrowHeight + let minTipX = self.cornerRadius + arrowHeight + + // Bounded value between min and max tip x + let tipX = max(minTipX, min(estimatedTipX, maxTipX)) + return tipX + } + + private func getUpArrowPath() -> CGPath { + let arrowHeight = Self.arrowHeight() + + let tipX = self.getTipX(arrowHeight: arrowHeight) + + let tip = CGPoint(x: tipX, y: 0) + let bottomLeftCorner = tip.applying(.init(translationX: -arrowHeight, y: arrowHeight)) + let bottomRightCorner = tip.applying(.init(translationX: arrowHeight, y: arrowHeight)) + + return self.getTrianglePath(point1: tip, point2: bottomLeftCorner, point3: bottomRightCorner) + } + + private func getDownArrowPath() -> CGPath { + let arrowHeight = Self.arrowHeight() + + let tipX = self.getTipX(arrowHeight: arrowHeight) + + let tip = CGPoint(x: tipX, y: self.frame.height) + let topLeftCorner = tip.applying(.init(translationX: -arrowHeight, y: -arrowHeight)) + let topRightCorner = tip.applying(.init(translationX: arrowHeight, y: -arrowHeight)) + + return self.getTrianglePath(point1: tip, point2: topLeftCorner, point3: topRightCorner) + } + + private func getTrianglePath(point1: CGPoint, point2: CGPoint, point3: CGPoint) -> CGPath { + let path = CGMutablePath() + path.move(to: point1) + path.addLine(to: point2) + path.addLine(to: point3) + path.addLine(to: point1) + return path + } +} diff --git a/core/Sources/Components/Popover/UIKit/PopoverViewController.swift b/core/Sources/Components/Popover/UIKit/PopoverViewController.swift new file mode 100644 index 000000000..8d2b80079 --- /dev/null +++ b/core/Sources/Components/Popover/UIKit/PopoverViewController.swift @@ -0,0 +1,99 @@ +// +// PopoverViewController.swift +// Spark +// +// Created by louis.borlee on 25/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit + +/// ViewController used as a container for the content of a Popover +/// It handles the background configuration such as color, inner spacings and the drawing of the arrow if needed +/// The content viewController is defined by the consumer, it should have a .clear background, no padding and have a well defined preferredContentSize for the popover to calculate its size properly +public final class PopoverViewController: UIViewController, UIPopoverPresentationControllerDelegate { + + private let contentViewController: UIViewController + private let viewModel: PopoverViewModel + + @ScaledUIMetric private var scaleFactor: CGFloat = 1.0 + + init(viewModel: PopoverViewModel, contentViewController: UIViewController) { + self.viewModel = viewModel + self.contentViewController = contentViewController + super.init(nibName: nil, bundle: nil) + self.modalPresentationStyle = .popover + self.popoverPresentationController?.delegate = self + + PopoverBackgroundConfiguration.showArrow = self.viewModel.showArrow + PopoverBackgroundConfiguration.backgroundColor = self.viewModel.colors.background.uiColor + PopoverBackgroundConfiguration.arrowSize = self.viewModel.arrowSize + self.popoverPresentationController?.popoverBackgroundViewClass = PopoverBackgroundView.self + } + + /// PopoverViewController initializer + /// - Parameters: + /// - contentViewController: The viewController that will be embedded in the popover: it should have a .clear background, no padding and have a well defined preferredContentSize for the popover to calculate its size properly + /// - theme: The theme of a Popover + /// - intent: The intent of the Popover + /// - showArrow: Boolean used to show or hide the tip arrow of the Popover + public convenience init(contentViewController: UIViewController, theme: Theme, intent: PopoverIntent, showArrow: Bool = true) { + self.init(viewModel: .init(theme: theme, intent: intent, showArrow: showArrow), contentViewController: contentViewController) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.removeFromChild() + } + + public override func viewDidLoad() { + super.viewDidLoad() + + self.view.backgroundColor = .clear + + self.contentViewController.willMove(toParent: self) + let view: UIView = self.contentViewController.view + view.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(view) + + let leadingConstraint = view.leadingAnchor.constraint( + greaterThanOrEqualTo: self.view.leadingAnchor, + constant: self.viewModel.spaces.horizontal + ) + leadingConstraint.priority = .required - 1 + let topConstraint = view.topAnchor.constraint( + greaterThanOrEqualTo: self.view.topAnchor, + constant: self.viewModel.spaces.vertical + ) + topConstraint.priority = .required - 1 + NSLayoutConstraint.activate([ + leadingConstraint, + topConstraint, + view.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor) + ]) + + self.addChild(self.contentViewController) + self.contentViewController.didMove(toParent: self) + + } + + private func removeFromChild() { + self.contentViewController.willMove(toParent: nil) + self.contentViewController.view?.removeFromSuperview() + self.contentViewController.removeFromParent() + self.contentViewController.didMove(toParent: nil) + } + + public func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .none + } + + public override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) { + self.preferredContentSize = self.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + } +} + diff --git a/core/Sources/Components/Popover/UIKit/UIViewController-presentPopover.swift b/core/Sources/Components/Popover/UIKit/UIViewController-presentPopover.swift new file mode 100644 index 000000000..e8ae41de2 --- /dev/null +++ b/core/Sources/Components/Popover/UIKit/UIViewController-presentPopover.swift @@ -0,0 +1,39 @@ +// +// UIViewController-presentPopover.swift +// Spark +// +// Created by louis.borlee on 25/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit + +public extension UIViewController { + + /// Presents a Spark Popover modally. + /// - Parameters: + /// - popoverViewControllerToPresent: The Spark Popover to display over the current view controller’s content + /// - sourceView: UIPopoverPresentationController.sourceView + /// - sourceRect: UIPopoverPresentationController.sourceRect + /// - permittedArrowDirections: UIPopoverPresentationController.permittedArrowDirections + /// - flag: Pass true to animate the presentation; otherwise, pass false. + /// - completion: The block to execute after the presentation finishes. This block has no return value and takes no parameters. You may specify nil for this parameter. + func presentPopover( + _ popoverViewControllerToPresent: PopoverViewController, + sourceView: UIView, + sourceRect: CGRect? = nil, + permittedArrowDirections: UIPopoverArrowDirection = .any, + animated flag: Bool = true, + completion: (() -> Void)? = nil + ) { + if let popoverPresentationController = popoverViewControllerToPresent.popoverPresentationController { + popoverPresentationController.passthroughViews = self.view.subviews + popoverPresentationController.sourceView = sourceView + if let sourceRect { + popoverPresentationController.sourceRect = sourceRect + } + popoverPresentationController.permittedArrowDirections = permittedArrowDirections + } + self.present(popoverViewControllerToPresent, animated: flag) + } +} diff --git a/core/Sources/Components/Popover/UseCase/GetColors/PopoverGetColorsUseCase.swift b/core/Sources/Components/Popover/UseCase/GetColors/PopoverGetColorsUseCase.swift new file mode 100644 index 000000000..0fd19a7aa --- /dev/null +++ b/core/Sources/Components/Popover/UseCase/GetColors/PopoverGetColorsUseCase.swift @@ -0,0 +1,71 @@ +// +// PopoverGetColorsUseCase.swift +// Spark +// +// Created by louis.borlee on 25/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +// sourcery: AutoMockable +protocol PopoverGetColorsUseCasable { + func execute(colors: Colors, intent: PopoverIntent) -> PopoverColors +} + +final class PopoverGetColorsUseCase: PopoverGetColorsUseCasable { + func execute(colors: Colors, intent: PopoverIntent) -> PopoverColors { + switch intent { + case .surface: + return .init( + background: colors.base.surface, + foreground: colors.base.onSurface + ) + case .main: + return .init( + background: colors.main.mainContainer, + foreground: colors.main.onMainContainer + ) + case .support: + return .init( + background: colors.support.supportContainer, + foreground: colors.support.onSupportContainer + ) + case .accent: + return .init( + background: colors.accent.accentContainer, + foreground: colors.accent.onAccentContainer + ) + case .basic: + return .init( + background: colors.basic.basicContainer, + foreground: colors.basic.onBasicContainer + ) + case .success: + return .init( + background: colors.feedback.successContainer, + foreground: colors.feedback.onSuccessContainer + ) + case .alert: + return .init( + background: colors.feedback.alertContainer, + foreground: colors.feedback.onAlertContainer + ) + case .error: + return .init( + background: colors.feedback.errorContainer, + foreground: colors.feedback.onErrorContainer + ) + case .info: + return .init( + background: colors.feedback.infoContainer, + foreground: colors.feedback.onInfoContainer + ) + case .neutral: + return .init( + background: colors.feedback.neutralContainer, + foreground: colors.feedback.onNeutralContainer + ) + } + } +} diff --git a/core/Sources/Components/Popover/UseCase/GetColors/PopoverGetColorsUseCaseTests.swift b/core/Sources/Components/Popover/UseCase/GetColors/PopoverGetColorsUseCaseTests.swift new file mode 100644 index 000000000..1e0606228 --- /dev/null +++ b/core/Sources/Components/Popover/UseCase/GetColors/PopoverGetColorsUseCaseTests.swift @@ -0,0 +1,106 @@ +// +// PopoverGetColorsUseCaseTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 25/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +@testable import SparkCore + +final class PopoverGetColorsUseCaseTests: XCTestCase { + + private let theme = ThemeGeneratedMock.mocked() + private let useCase = PopoverGetColorsUseCase() + + func test_surface() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .surface) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.base.surface), "Wrong background color for intent .surface") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.base.onSurface), "Wrong foreground color for intent .surface") + } + + func test_main() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .main) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.main.mainContainer), "Wrong background color for intent .main") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.main.onMainContainer), "Wrong foreground color for intent .main") + } + + func test_support() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .support) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.support.supportContainer), "Wrong background color for intent .support") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.support.onSupportContainer), "Wrong foreground color for intent .support") + } + + func test_accent() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .accent) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.accent.accentContainer), "Wrong background color for intent .accent") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.accent.onAccentContainer), "Wrong foreground color for intent .accent") + } + + func test_basic() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .basic) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.basic.basicContainer), "Wrong background color for intent .basic") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.basic.onBasicContainer), "Wrong foreground color for intent .basic") + } + + func test_success() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .success) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.feedback.successContainer), "Wrong background color for intent .success") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.feedback.onSuccessContainer), "Wrong foreground color for intent .success") + } + + func test_alert() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .alert) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.feedback.alertContainer), "Wrong background color for intent .alert") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.feedback.onAlertContainer), "Wrong foreground color for intent .alert") + } + + func test_error() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .error) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.feedback.errorContainer), "Wrong background color for intent .error") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.feedback.onErrorContainer), "Wrong foreground color for intent .error") + } + + func test_info() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .info) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.feedback.infoContainer), "Wrong background color for intent .info") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.feedback.onInfoContainer), "Wrong foreground color for intent .info") + } + + func test_neutral() { + // WHEN + let colors = self.useCase.execute(colors: self.theme.colors, intent: .neutral) + + // THEN + XCTAssertTrue(colors.background.equals(self.theme.colors.feedback.neutralContainer), "Wrong background color for intent .neutral") + XCTAssertTrue(colors.foreground.equals(self.theme.colors.feedback.onNeutralContainer), "Wrong foreground color for intent .neutral") + } +} diff --git a/core/Sources/Components/Popover/UseCase/GetSpaces/PopoverGetSpacesUseCase.swift b/core/Sources/Components/Popover/UseCase/GetSpaces/PopoverGetSpacesUseCase.swift new file mode 100644 index 000000000..82c228db6 --- /dev/null +++ b/core/Sources/Components/Popover/UseCase/GetSpaces/PopoverGetSpacesUseCase.swift @@ -0,0 +1,20 @@ +// +// PopoverGetSpacesUseCase.swift +// SparkCore +// +// Created by louis.borlee on 25/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +// sourcery: AutoMockable +protocol PopoverGetSpacesUseCasable { + func execute(layoutSpacing: LayoutSpacing) -> PopoverSpaces +} + +final class PopoverGetSpacesUseCase: PopoverGetSpacesUseCasable { + func execute(layoutSpacing: any LayoutSpacing) -> PopoverSpaces { + return .init(horizontal: layoutSpacing.large, vertical: layoutSpacing.large) + } +} diff --git a/core/Sources/Components/Popover/UseCase/GetSpaces/PopoverGetSpacesUseCaseTests.swift b/core/Sources/Components/Popover/UseCase/GetSpaces/PopoverGetSpacesUseCaseTests.swift new file mode 100644 index 000000000..9ade19639 --- /dev/null +++ b/core/Sources/Components/Popover/UseCase/GetSpaces/PopoverGetSpacesUseCaseTests.swift @@ -0,0 +1,26 @@ +// +// PopoverGetSpacesUseCaseTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 25/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +@testable import SparkCore + +final class PopoverGetSpacesUseCaseTests: XCTestCase { + + private let theme = ThemeGeneratedMock.mocked() + private let useCase = PopoverGetSpacesUseCase() + + func test() { + // WHEN + let spaces = self.useCase.execute(layoutSpacing: self.theme.layout.spacing) + + // THEN + XCTAssertEqual(spaces.horizontal, self.theme.layout.spacing.large, "Wrong horizontal spacing") + XCTAssertEqual(spaces.vertical, self.theme.layout.spacing.large, "Wrong vertical spacing") + } + +} diff --git a/core/Sources/Components/Popover/ViewModel/PopoverViewModel.swift b/core/Sources/Components/Popover/ViewModel/PopoverViewModel.swift new file mode 100644 index 000000000..fcc7dfc21 --- /dev/null +++ b/core/Sources/Components/Popover/ViewModel/PopoverViewModel.swift @@ -0,0 +1,29 @@ +// +// PopoverViewModel.swift +// Spark +// +// Created by louis.borlee on 26/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import Foundation + +struct PopoverViewModel { + let colors: PopoverColors + let spaces: PopoverSpaces + let showArrow: Bool + let arrowSize: CGFloat + + init( + theme: Theme, + intent: PopoverIntent, + showArrow: Bool, + getColorsUseCase: PopoverGetColorsUseCasable = PopoverGetColorsUseCase(), + getSpacesUseCase: PopoverGetSpacesUseCasable = PopoverGetSpacesUseCase() + ) { + self.colors = getColorsUseCase.execute(colors: theme.colors, intent: intent) + self.spaces = getSpacesUseCase.execute(layoutSpacing: theme.layout.spacing) + self.showArrow = showArrow + self.arrowSize = theme.layout.spacing.medium + } +} diff --git a/core/Sources/Components/Popover/ViewModel/PopoverViewModelTests.swift b/core/Sources/Components/Popover/ViewModel/PopoverViewModelTests.swift new file mode 100644 index 000000000..1f9e6e172 --- /dev/null +++ b/core/Sources/Components/Popover/ViewModel/PopoverViewModelTests.swift @@ -0,0 +1,69 @@ +// +// PopoverViewModelTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 26/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import XCTest +@testable import SparkCore + +final class PopoverViewModelTests: XCTestCase { + + private let theme = ThemeGeneratedMock.mocked() + + func test_init() throws { + // GIVEN + let getColorsUseCaseMock = PopoverGetColorsUseCasableGeneratedMock() + getColorsUseCaseMock.executeWithColorsAndIntentReturnValue = .init( + background: self.theme.colors.feedback.alertContainer, + foreground: self.theme.colors.basic.basicContainer + ) + + let getSpacesUseCaseMock = PopoverGetSpacesUseCasableGeneratedMock() + getSpacesUseCaseMock.executeWithLayoutSpacingReturnValue = .init( + horizontal: 4, + vertical: 2 + ) + + // WHEN + let viewModel = PopoverViewModel( + theme: self.theme, + intent: .basic, + showArrow: true, + getColorsUseCase: getColorsUseCaseMock, + getSpacesUseCase: getSpacesUseCaseMock + ) + + // THEN - Values + XCTAssertTrue(viewModel.colors.background.equals(self.theme.colors.feedback.alertContainer), "Wrong colors.background") + XCTAssertTrue(viewModel.colors.foreground.equals(self.theme.colors.basic.basicContainer), "Wrong colors.foreground") + + XCTAssertEqual(viewModel.spaces.horizontal, 4.0, "Wrong spaces.horizontal") + XCTAssertEqual(viewModel.spaces.vertical, 2.0, "Wrong spaces.vertical") + + XCTAssertTrue(viewModel.showArrow, "showArrow should be true") + + XCTAssertEqual(viewModel.arrowSize, self.theme.layout.spacing.medium, "Wrong arrowSize") + + // THEN - GetColorsUseCase + XCTAssertEqual(getColorsUseCaseMock.executeWithColorsAndIntentCallsCount, 1, "getColorsUseCaseMock.executeWithColorsAndIntent should have been called once") + let getColorsUseCaseReceivedArguments = try XCTUnwrap(getColorsUseCaseMock.executeWithColorsAndIntentReceivedArguments, "Couldn't unwrap getColorsUseCaseMock.executeWithColorsAndIntentRe") + XCTAssertIdentical( + getColorsUseCaseReceivedArguments.colors as? ColorsGeneratedMock, + self.theme.colors as? ColorsGeneratedMock, + "Wrong getColorsUseCaseReceivedArguments.colors" + ) + XCTAssertEqual(getColorsUseCaseReceivedArguments.intent, .basic, "Wrong getColorsUseCaseReceivedArguments.intent") + + // THEN - GetSpacesUseCase + XCTAssertEqual(getSpacesUseCaseMock.executeWithLayoutSpacingCallsCount, 1, "getSpacesUseCaseMock.executeWithLayoutSpacing should have been called once") + let getSpacesUseCaseReceivedLayoutSpacing = try XCTUnwrap(getSpacesUseCaseMock.executeWithLayoutSpacingReceivedLayoutSpacing, "Couldn't unwrap getSpacesUseCaseMock.executeWithLayoutSpacingReceivedLayoutSpacing") + XCTAssertIdentical( + getSpacesUseCaseReceivedLayoutSpacing as? LayoutSpacingGeneratedMock, + self.theme.layout.spacing as? LayoutSpacingGeneratedMock, + "Wrong getSpacesUseCaseReceivedLayoutSpacing" + ) + } +} diff --git a/spark/Demo/Classes/Enum/UIComponent.swift b/spark/Demo/Classes/Enum/UIComponent.swift index da951380f..2b118d23a 100644 --- a/spark/Demo/Classes/Enum/UIComponent.swift +++ b/spark/Demo/Classes/Enum/UIComponent.swift @@ -18,6 +18,7 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { .chip, .formField, .icon, + .popover, .progressBarIndeterminate, .progressBarSingle, .progressTracker, @@ -48,6 +49,7 @@ struct UIComponent: RawRepresentable, CaseIterable, Equatable { static let progressBarIndeterminate = UIComponent(rawValue: "Progress Bar Indeterminate") static let progressBarSingle = UIComponent(rawValue: "Progress Bar Single") static let progressTracker = UIComponent(rawValue: "Progress Tracker") + static let popover = UIComponent(rawValue: "Popover") static let radioButton = UIComponent(rawValue: "Radio Button") static let ratingDisplay = UIComponent(rawValue: "Rating Display") static let ratingInput = UIComponent(rawValue: "Rating Input") diff --git a/spark/Demo/Classes/View/Components/ComponentsViewController.swift b/spark/Demo/Classes/View/Components/ComponentsViewController.swift index 0acc6f9ae..fa6876b9e 100644 --- a/spark/Demo/Classes/View/Components/ComponentsViewController.swift +++ b/spark/Demo/Classes/View/Components/ComponentsViewController.swift @@ -87,6 +87,8 @@ extension ComponentsViewController { viewController = FormFieldComponentUIViewController.build() case .icon: viewController = IconComponentUIViewController.build() + case .popover: + viewController = PopoverPresentingUIViewController.build() case .progressBarIndeterminate: viewController = ProgressBarIndeterminateComponentUIViewController.build() case .progressBarSingle: diff --git a/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverContentDemoViewController.swift b/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverContentDemoViewController.swift new file mode 100644 index 000000000..c5a8dff94 --- /dev/null +++ b/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverContentDemoViewController.swift @@ -0,0 +1,49 @@ +// +// PopoverContentDemoViewController.swift +// SparkDemo +// +// Created by louis.borlee on 26/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import SparkCore + +final class PopoverContentDemoViewController: UIViewController { + + let label: UILabel = { + let label = UILabel() + label.text = "This is a label that should be multiline, depending on the content size. It has a lessThanOrEqualToConstant: 300 constraint" + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + init(theme: Theme, intent: PopoverIntent) { + super.init(nibName: nil, bundle: nil) + self.label.textColor = intent.getColors(theme: theme).foreground.uiColor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .clear + + self.view.addSubview(self.label) + NSLayoutConstraint.activate([ + self.label.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 0), + self.label.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0), + self.label.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0), + self.label.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0), + self.label.widthAnchor.constraint(lessThanOrEqualToConstant: 300) + ]) + + } + + override func viewDidLayoutSubviews() { + self.preferredContentSize = self.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + } +} diff --git a/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverPresentingUIViewController.swift b/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverPresentingUIViewController.swift new file mode 100644 index 000000000..18747e3f1 --- /dev/null +++ b/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverPresentingUIViewController.swift @@ -0,0 +1,46 @@ +// +// PopoverPresentingUIViewController.swift +// SparkDemo +// +// Created by louis.borlee on 26/06/2024. +// Copyright © 2024 Adevinta. All rights reserved. +// + +import UIKit +import SparkCore + +final class PopoverPresentingUIViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .systemBackground + let button = UIButton(configuration: .filled()) + button.setTitle("Show Popover", for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(button) + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + button.centerYAnchor.constraint(equalTo: self.view.centerYAnchor) + ]) + + button.addAction(.init(handler: { [weak self] _ in + guard let self else { return } + if let presentedViewController { + presentedViewController.dismiss(animated: true) + } else { + let theme = SparkTheme() + let intent = PopoverIntent.main + let popoverViewController = PopoverViewController(contentViewController: PopoverContentDemoViewController(theme: theme, intent: intent), theme: theme, intent: intent, showArrow: true) + self.presentPopover(popoverViewController, sourceView: button) + } + }), for: .touchUpInside) + } +} + +// MARK: - Builder +extension PopoverPresentingUIViewController { + + static func build() -> PopoverPresentingUIViewController { + return PopoverPresentingUIViewController() + } +} From ce11468d4c031018429e93a3dc8320385f1371ec Mon Sep 17 00:00:00 2001 From: "louis.borlee" Date: Thu, 27 Jun 2024 09:43:54 +0200 Subject: [PATCH 2/3] [Popover#903] Upgraded demo --- .../PopoverPresentingUIViewController.swift | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverPresentingUIViewController.swift b/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverPresentingUIViewController.swift index 18747e3f1..4ea156f88 100644 --- a/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverPresentingUIViewController.swift +++ b/spark/Demo/Classes/View/Components/Popover/UIKit/PopoverPresentingUIViewController.swift @@ -11,29 +11,45 @@ import SparkCore final class PopoverPresentingUIViewController: UIViewController { + private let theme = SparkTheme() + override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = .systemBackground - let button = UIButton(configuration: .filled()) - button.setTitle("Show Popover", for: .normal) - button.translatesAutoresizingMaskIntoConstraints = false - self.view.addSubview(button) + + let buttons: [UIView] = PopoverIntent.allCases.enumerated().map { index, intent in + let popoverColors = intent.getColors(theme: self.theme) + let button = UIButton(configuration: .filled()) + button.setTitle(intent.name, for: .normal) + button.setTitleColor(popoverColors.foreground.uiColor, for: .normal) + button.tintColor = popoverColors.background.uiColor + button.addAction(.init(handler: { [weak self] _ in + self?.showPopover(sourceView: button, intent: intent, withArrow: index % 2 == 0) + }), for: .touchUpInside) + return button + } + + let stackView = UIStackView(arrangedSubviews: buttons) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = 12 + + self.view.addSubview(stackView) NSLayoutConstraint.activate([ - button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), - button.centerYAnchor.constraint(equalTo: self.view.centerYAnchor) + stackView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor) ]) + } - button.addAction(.init(handler: { [weak self] _ in - guard let self else { return } - if let presentedViewController { - presentedViewController.dismiss(animated: true) - } else { - let theme = SparkTheme() - let intent = PopoverIntent.main - let popoverViewController = PopoverViewController(contentViewController: PopoverContentDemoViewController(theme: theme, intent: intent), theme: theme, intent: intent, showArrow: true) - self.presentPopover(popoverViewController, sourceView: button) - } - }), for: .touchUpInside) + private func showPopover(sourceView: UIView, intent: PopoverIntent, withArrow showArrow: Bool) { + if let presentedViewController { + presentedViewController.dismiss(animated: true) + } else { + let theme = SparkTheme() + let popoverViewController = PopoverViewController(contentViewController: PopoverContentDemoViewController(theme: theme, intent: intent), theme: theme, intent: intent, showArrow: showArrow) + self.presentPopover(popoverViewController, sourceView: sourceView) + } } } From 190c43d6588f71ab70b74519f622521fcc67e976 Mon Sep 17 00:00:00 2001 From: Michael Zimmermann Date: Tue, 2 Jul 2024 12:17:58 +0200 Subject: [PATCH 3/3] [Popover#903] Fix swiftlint. --- .../Components/Popover/UIKit/PopoverBackgroundView.swift | 9 +++------ .../Components/Popover/UIKit/PopoverViewController.swift | 2 +- .../Popover/UIKit/UIViewController-presentPopover.swift | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/core/Sources/Components/Popover/UIKit/PopoverBackgroundView.swift b/core/Sources/Components/Popover/UIKit/PopoverBackgroundView.swift index bd507bbf0..5cc720213 100644 --- a/core/Sources/Components/Popover/UIKit/PopoverBackgroundView.swift +++ b/core/Sources/Components/Popover/UIKit/PopoverBackgroundView.swift @@ -106,7 +106,6 @@ final class PopoverBackgroundView: UIPopoverBackgroundView { } default: self.previouslyModifiedConstraint = nil - break } self.arrowShape?.removeFromSuperlayer() @@ -137,8 +136,7 @@ final class PopoverBackgroundView: UIPopoverBackgroundView { let minTipY = self.cornerRadius + arrowHeight // Bounded value between min and max tip y - let tipY = max(minTipY, min(estimatedTipY, maxTipY)) - return tipY + return max(minTipY, min(estimatedTipY, maxTipY)) } private func getLeftArrowPath() -> CGPath { @@ -146,7 +144,7 @@ final class PopoverBackgroundView: UIPopoverBackgroundView { let tipY = self.getTipY(arrowHeight: arrowHeight) - let tip = CGPoint(x: 0, y: tipY) + let tip = CGPoint(x: 0, y: tipY) let topRightCorner = tip.applying(.init(translationX: arrowHeight, y: -arrowHeight)) let bottomRightCorner = tip.applying(.init(translationX: arrowHeight, y: arrowHeight)) @@ -172,8 +170,7 @@ final class PopoverBackgroundView: UIPopoverBackgroundView { let minTipX = self.cornerRadius + arrowHeight // Bounded value between min and max tip x - let tipX = max(minTipX, min(estimatedTipX, maxTipX)) - return tipX + return max(minTipX, min(estimatedTipX, maxTipX)) } private func getUpArrowPath() -> CGPath { diff --git a/core/Sources/Components/Popover/UIKit/PopoverViewController.swift b/core/Sources/Components/Popover/UIKit/PopoverViewController.swift index 8d2b80079..e91266361 100644 --- a/core/Sources/Components/Popover/UIKit/PopoverViewController.swift +++ b/core/Sources/Components/Popover/UIKit/PopoverViewController.swift @@ -30,7 +30,7 @@ public final class PopoverViewController: UIViewController, UIPopoverPresentatio PopoverBackgroundConfiguration.arrowSize = self.viewModel.arrowSize self.popoverPresentationController?.popoverBackgroundViewClass = PopoverBackgroundView.self } - + /// PopoverViewController initializer /// - Parameters: /// - contentViewController: The viewController that will be embedded in the popover: it should have a .clear background, no padding and have a well defined preferredContentSize for the popover to calculate its size properly diff --git a/core/Sources/Components/Popover/UIKit/UIViewController-presentPopover.swift b/core/Sources/Components/Popover/UIKit/UIViewController-presentPopover.swift index e8ae41de2..77b8f1184 100644 --- a/core/Sources/Components/Popover/UIKit/UIViewController-presentPopover.swift +++ b/core/Sources/Components/Popover/UIKit/UIViewController-presentPopover.swift @@ -9,7 +9,7 @@ import UIKit public extension UIViewController { - + /// Presents a Spark Popover modally. /// - Parameters: /// - popoverViewControllerToPresent: The Spark Popover to display over the current view controller’s content