Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Popover#903] Added UIKit Popover helpers #1025

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions core/Sources/Components/Popover/Model/PopoverColors.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
39 changes: 39 additions & 0 deletions core/Sources/Components/Popover/Model/PopoverIntent.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
48 changes: 48 additions & 0 deletions core/Sources/Components/Popover/Model/PopoverIntentTests.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}

}
14 changes: 14 additions & 0 deletions core/Sources/Components/Popover/Model/PopoverSpaces.swift
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
208 changes: 208 additions & 0 deletions core/Sources/Components/Popover/UIKit/PopoverBackgroundView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//
// 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
}

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
return max(minTipY, min(estimatedTipY, maxTipY))
}

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
return max(minTipX, min(estimatedTipX, maxTipX))
}

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
}
}
Loading
Loading