Merge branch 'mbruce/bugfix' into 'develop'

Bugs merged

See merge request BPHV_MIPS/vds_ios!264
This commit is contained in:
Bruce, Matt R 2024-06-26 14:52:01 +00:00
commit f4892918e3
14 changed files with 719 additions and 200 deletions

View File

@ -152,7 +152,7 @@
EAC58C182BED0E2300BA39FA /* SecurityCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C172BED0E2300BA39FA /* SecurityCode.swift */; };
EAC58C232BF2824200BA39FA /* DatePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C222BF2824200BA39FA /* DatePicker.swift */; };
EAC58C272BF4116200BA39FA /* DatePickerCalendarModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C262BF4116200BA39FA /* DatePickerCalendarModel.swift */; };
EAC58C292BF4118C00BA39FA /* DatePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C282BF4118C00BA39FA /* DatePickerViewController.swift */; };
EAC58C292BF4118C00BA39FA /* ClearPopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC58C282BF4118C00BA39FA /* ClearPopoverViewController.swift */; };
EAC71A1D2A2E155A00E47A9F /* Checkbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC71A1C2A2E155A00E47A9F /* Checkbox.swift */; };
EAC71A1F2A2E173D00E47A9F /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC71A1E2A2E173D00E47A9F /* RadioButton.swift */; };
EAC846F3294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC846F2294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift */; };
@ -174,6 +174,7 @@
EAF1FE9B29DB1A6000101452 /* Changeable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1FE9A29DB1A6000101452 /* Changeable.swift */; };
EAF2F4762C231EAA007BFEDC /* AccessibilityActionElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF2F4752C231EAA007BFEDC /* AccessibilityActionElement.swift */; };
EAF2F4782C249D72007BFEDC /* AccessibilityUpdatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF2F4772C249D72007BFEDC /* AccessibilityUpdatable.swift */; };
EAF2F4892C2A1075007BFEDC /* AlertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF2F4882C2A1075007BFEDC /* AlertViewController.swift */; };
EAF7F0952899861000B287F5 /* CheckboxItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0932899861000B287F5 /* CheckboxItem.swift */; };
EAF7F09A2899B17200B287F5 /* CATransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F0992899B17200B287F5 /* CATransaction.swift */; };
EAF7F0A0289AB7EC00B287F5 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF7F09F289AB7EC00B287F5 /* View.swift */; };
@ -368,7 +369,7 @@
EAC58C222BF2824200BA39FA /* DatePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePicker.swift; sourceTree = "<group>"; };
EAC58C242BF2A7FB00BA39FA /* DatePickerChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = DatePickerChangeLog.txt; sourceTree = "<group>"; };
EAC58C262BF4116200BA39FA /* DatePickerCalendarModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerCalendarModel.swift; sourceTree = "<group>"; };
EAC58C282BF4118C00BA39FA /* DatePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerViewController.swift; sourceTree = "<group>"; };
EAC58C282BF4118C00BA39FA /* ClearPopoverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearPopoverViewController.swift; sourceTree = "<group>"; };
EAC71A1C2A2E155A00E47A9F /* Checkbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Checkbox.swift; sourceTree = "<group>"; };
EAC71A1E2A2E173D00E47A9F /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = "<group>"; };
EAC846F2294B95CE00F685BA /* ButtonGroupCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGroupCollectionViewCell.swift; sourceTree = "<group>"; };
@ -402,6 +403,7 @@
EAF1FE9A29DB1A6000101452 /* Changeable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Changeable.swift; sourceTree = "<group>"; };
EAF2F4752C231EAA007BFEDC /* AccessibilityActionElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityActionElement.swift; sourceTree = "<group>"; };
EAF2F4772C249D72007BFEDC /* AccessibilityUpdatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityUpdatable.swift; sourceTree = "<group>"; };
EAF2F4882C2A1075007BFEDC /* AlertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertViewController.swift; sourceTree = "<group>"; };
EAF7F0932899861000B287F5 /* CheckboxItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CheckboxItem.swift; sourceTree = "<group>"; };
EAF7F0992899B17200B287F5 /* CATransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CATransaction.swift; sourceTree = "<group>"; };
EAF7F09F289AB7EC00B287F5 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
@ -746,9 +748,11 @@
isa = PBXGroup;
children = (
EA985C1C296CD13600F2FF2E /* BundleManager.swift */,
EAC58C282BF4118C00BA39FA /* ClearPopoverViewController.swift */,
EAF7F0B8289C139800B287F5 /* ColorConfiguration.swift */,
EAB5FEF02927F4AA00998C17 /* SelfSizingCollectionView.swift */,
EAF2F4752C231EAA007BFEDC /* AccessibilityActionElement.swift */,
EAF2F4882C2A1075007BFEDC /* AlertViewController.swift */,
);
path = Classes;
sourceTree = "<group>";
@ -966,7 +970,6 @@
children = (
EAC58C222BF2824200BA39FA /* DatePicker.swift */,
EAC58C262BF4116200BA39FA /* DatePickerCalendarModel.swift */,
EAC58C282BF4118C00BA39FA /* DatePickerViewController.swift */,
EAC58C242BF2A7FB00BA39FA /* DatePickerChangeLog.txt */,
);
path = DatePicker;
@ -1304,12 +1307,13 @@
EA3361BD288B2C760071C351 /* TypeAlias.swift in Sources */,
EAC58C0A2BED004E00BA39FA /* FieldType.swift in Sources */,
EA471F3A2A95587500CE9E58 /* LayoutConstraintable.swift in Sources */,
EAC58C292BF4118C00BA39FA /* DatePickerViewController.swift in Sources */,
EAC58C292BF4118C00BA39FA /* ClearPopoverViewController.swift in Sources */,
EAF193432C134F3800C68D18 /* TableCellItem.swift in Sources */,
EAB1D2CF28ABEF2B00DAE764 /* Typography+Base.swift in Sources */,
EA0D1C3B2A6AD51B00E5C127 /* Typogprahy+Styles.swift in Sources */,
EAF7F09A2899B17200B287F5 /* CATransaction.swift in Sources */,
EAC58C162BED0E0300BA39FA /* InlineAction.swift in Sources */,
EAF2F4892C2A1075007BFEDC /* AlertViewController.swift in Sources */,
EA0D1C3D2A6AD57600E5C127 /* Typography+Enums.swift in Sources */,
EAF1FE9B29DB1A6000101452 /* Changeable.swift in Sources */,
EAC58C0C2BED01D500BA39FA /* Telephone.swift in Sources */,
@ -1531,7 +1535,7 @@
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 67;
CURRENT_PROJECT_VERSION = 68;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = "";
DYLIB_COMPATIBILITY_VERSION = 1;
@ -1569,7 +1573,7 @@
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CODE_SIGN_IDENTITY = "";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 67;
CURRENT_PROJECT_VERSION = 68;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = "";
DYLIB_COMPATIBILITY_VERSION = 1;

View File

@ -0,0 +1,96 @@
//
// AlertViewController.swift
// VDS
//
// Created by Matt Bruce on 6/24/24.
//
import Foundation
import UIKit
import Combine
import VDSCoreTokens
open class AlertViewController: UIViewController, Surfaceable {
/// Set of Subscribers for any Publishers for this Control.
open var subscribers = Set<AnyCancellable>()
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
private var onClickSubscriber: AnyCancellable? {
willSet {
if let onClickSubscriber {
onClickSubscriber.cancel()
}
}
}
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
/// Current Surface and this is used to pass down to child objects that implement Surfacable
open var surface: Surface = .light { didSet { updateView() }}
open var presenter: UIView? { didSet { updateView() }}
open var dialog: UIView!
//--------------------------------------------------
// MARK: - Configuration
//--------------------------------------------------
private let backgroundColorConfiguration = SurfaceColorConfiguration(VDSColor.backgroundPrimaryDark, VDSColor.backgroundPrimaryLight)
//--------------------------------------------------
// MARK: - Lifecycle
//--------------------------------------------------
open override func viewDidLoad() {
super.viewDidLoad()
isModalInPresentation = true
setup()
}
open override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
UIAccessibility.post(notification: .screenChanged, argument: dialog)
}
private func dismiss() {
dismiss(animated: true) { [weak self] in
guard let self, let presenter else { return }
UIAccessibility.post(notification: .layoutChanged, argument: presenter)
}
}
open func setup() {
guard let dialog else { return }
view.accessibilityElements = [dialog]
view.addSubview(dialog)
// Activate constraints
NSLayoutConstraint.activate([
// Constraints for the floating modal view
dialog.centerXAnchor.constraint(equalTo: view.centerXAnchor),
dialog.centerYAnchor.constraint(equalTo: view.centerYAnchor),
dialog.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 10),
dialog.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -10),
dialog.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor, constant: 10),
dialog.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor, constant: -10)
])
}
open override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: view)
if dialog.frame.contains(location) {
super.touchesBegan(touches, with: event)
} else {
dismiss()
}
}
/// Used to make changes to the View based off a change events or from local properties.
open func updateView() {
view.backgroundColor = backgroundColorConfiguration.getColor(self).withAlphaComponent(0.3)
if var dialog = dialog as? Surfaceable {
dialog.surface = surface
}
}
}

View File

@ -0,0 +1,152 @@
//
// DatePickerPopoverViewController.swift
// VDS
//
// Created by Matt Bruce on 5/14/24.
//
import Foundation
import UIKit
open class ClearPopoverViewController: UIViewController, UIPopoverPresentationControllerDelegate {
/// The view to be inserted inside the popover
private var contentView: UIView!
/// An object representing the arrow of the popover.
private var arrow: UIPopoverArrowDirection
/// Popover presentation controller of the popover
private var popOver: UIPopoverPresentationController!
open var maxWidth: CGFloat?
open var sourceRect: CGRect?
open var spacing: CGFloat = 0
/**
A controller that manages the popover.
- Parameter contentView: The view to be inserted inside the popover.
- Parameter design: An object used for defining visual attributes of the popover.
- Parameter arrow: An object representing the arrow in popover.
- Parameter sourceView: The view containing the anchor rectangle for the popover.
- Parameter sourceRect: The rectangle in the specified view in which to anchor the popover.
- Parameter barButtonItem: The bar button item on which to anchor the popover.
Assign a value to `barButton` to anchor the popover to the specified bar button item. When presented, the popovers arrow points to the specified item. Alternatively, you may specify the anchor location for the popover using the `sourceView` and `sourceRect` properties.
*/
public init(contentView: UIView, arrow: UIPopoverArrowDirection, sourceView: UIView? = nil, sourceRect: CGRect? = nil, spacing: CGFloat = 0, barButtonItem: UIBarButtonItem? = nil) {
self.contentView = contentView
self.spacing = spacing
self.arrow = arrow
self.sourceRect = sourceRect
super.init(nibName: nil, bundle: nil)
setupPopover(sourceView, sourceRect, barButtonItem)
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
open override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
view.superview?.accessibilityIdentifier = "HadCornerRadius"
view.accessibilityIdentifier = "PopoverViewController.View"
contentView.accessibilityIdentifier = "PopoverViewController.ContentView"
view.superview?.layer.cornerRadius = 0
}
open override func viewDidLayoutSubviews() {
contentView.frame.origin = CGPoint(x: 0, y: 0)
}
///Sets up the Popover and starts the timer for its closing.
private func setupPopover(_ sourceView: UIView?, _ sourceRect: CGRect?, _ barButtonItem: UIBarButtonItem?) {
modalPresentationStyle = .popover
view.addSubview(contentView)
popOver = self.popoverPresentationController!
popOver.popoverLayoutMargins = .zero
popOver.popoverBackgroundViewClass = ClearPopoverBackgroundView.self
popOver.sourceView = sourceView
popOver.popoverLayoutMargins = .zero
if let sourceRect = sourceRect {
popOver.sourceRect = sourceRect
}
popOver.barButtonItem = barButtonItem
popOver.delegate = self
popOver.permittedArrowDirections = arrow
popOver.backgroundColor = .clear
}
open func popoverPresentationController(_ popoverPresentationController: UIPopoverPresentationController, willRepositionPopoverTo rect: UnsafeMutablePointer<CGRect>, in view: AutoreleasingUnsafeMutablePointer<UIView>) {
if let presentedView = popoverPresentationController.presentedViewController.view.superview {
presentedView.layer.cornerRadius = 0
}
}
private func updatePopoverPosition() {
guard let popoverPresentationController = popoverPresentationController else { return }
if let sourceView = popoverPresentationController.sourceView, let sourceRect {
popoverPresentationController.sourceRect = sourceRect
}
}
// Ensure to handle rotations
open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { [weak self] _ in
self?.updatePopoverPosition()
})
}
open func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
return .none
}
// Returns presentation controller of the popover
open func getPopoverPresentationController() -> UIPopoverPresentationController {
return popOver
}
}
open class ClearPopoverBackgroundView: UIPopoverBackgroundView {
open override var arrowOffset: CGFloat {
get { 0 }
set { }
}
open override var arrowDirection: UIPopoverArrowDirection {
get { .any }
set { }
}
open override class var wantsDefaultContentAppearance: Bool {
false
}
open override class func contentViewInsets() -> UIEdgeInsets{
.zero
}
open override class func arrowHeight() -> CGFloat {
0
}
open override class func arrowBase() -> CGFloat{
0
}
open override func layoutSubviews() {
super.layoutSubviews()
layer.shadowOpacity = 0
layer.shadowRadius = 0
layer.cornerRadius = 0
}
open override func draw(_ rect: CGRect) {
}
}

View File

@ -114,6 +114,11 @@ open class ButtonBase: UIButton, ViewProtocol, UserInfoable, Clickable {
titleLabel?.adjustsFontSizeToFitWidth = false
titleLabel?.lineBreakMode = .byTruncatingTail
titleLabel?.numberOfLines = 1
bridge_accessibilityLabelBlock = { [weak self] in
guard let self else { return nil }
return text
}
}
open func updateView() {

View File

@ -5,7 +5,7 @@ import Combine
/// A dropdown select is an expandable menu of predefined options that allows a customer to make a single selection.
@objc(VDSDatePicker)
open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopoverPresentationControllerDelegate {
open class DatePicker: EntryFieldBase {
//--------------------------------------------------
// MARK: - Initializers
//--------------------------------------------------
@ -26,12 +26,19 @@ open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopov
//--------------------------------------------------
/// A callback when the selected option changes. Passes parameters (option).
open var onDateSelected: ((Date, DatePicker) -> Void)?
//--------------------------------------------------
// MARK: - Private Properties
//--------------------------------------------------
class Responder: UIView {
open override var canBecomeFirstResponder: Bool {
true
}
}
internal override var responder: UIResponder? { hiddenView }
internal var hiddenView = Responder().with { $0.width(0) }
internal var minWidthDefault = 186.0
internal var bottomStackView: UIStackView = {
return UIStackView().with {
$0.translatesAutoresizingMaskIntoConstraints = false
@ -41,6 +48,33 @@ open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopov
$0.spacing = VDSLayout.space2X
}
}()
//--------------------------------------------------
// MARK: - Private Popover/Alert Properties
//--------------------------------------------------
/// View shown inline
internal var popoverOverlayView = UIView().with {
$0.backgroundColor = .clear
$0.translatesAutoresizingMaskIntoConstraints = false
}
/// use this to track touch events outside of the popover in the overlay
internal var popupOverlayTapGesture: AnyCancellable?
/// View shown inline
internal var popoverView: UIView!
/// Size used for the popover
internal var popoverViewSize: CGSize = .zero
/// Spacing between the popover and the ContainerView when not a AlertViewController
internal var popoverSpacing: CGFloat = VDSLayout.space1X
/// Whether or not the popover is visible
internal var popoverVisible = false
/// If the ContainerView exists somewhere in the superview hierarch in a ScrollView.
internal var scrollView: UIScrollView?
/// Original Found ScrollView ContentSize, this will get reset back to this size when the Popover is removed.
internal var scrollViewContentSize: CGSize?
/// Presenting ViewController with showing the AlertViewController Version.
internal var topViewController: UIViewController?
//--------------------------------------------------
// MARK: - Public Properties
@ -87,12 +121,12 @@ open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopov
}
open var dateFormat: DateFormat = .shortNumeric { didSet{ setNeedsUpdate() } }
//--------------------------------------------------
// MARK: - Configuration Properties
//--------------------------------------------------
internal override var containerSize: CGSize { CGSize(width: minWidthDefault, height: 44) }
//--------------------------------------------------
// MARK: - Overrides
//--------------------------------------------------
@ -100,20 +134,29 @@ open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopov
/// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations.
open override func setup() {
super.setup()
// setting color config
selectedDateLabel.textColorConfiguration = primaryColorConfiguration.eraseToAnyColorable()
// tap gesture
containerView
.publisher(for: UITapGestureRecognizer())
.sink { [weak self] _ in
guard let self else { return }
if self.isEnabled && !self.isReadOnly {
self.togglePicker()
if isEnabled && !isReadOnly {
showPopover()
}
}
.store(in: &subscribers)
NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification).sink { [weak self] _ in
guard let self else { return }
hidePopoverView()
}
.store(in: &subscribers)
popoverOverlayView.isHidden = true
}
open override func getFieldContainer() -> UIView {
@ -125,9 +168,10 @@ open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopov
}
controlStackView.addArrangedSubview(calendarIcon)
controlStackView.addArrangedSubview(selectedDateLabel)
controlStackView.addArrangedSubview(hiddenView)
return controlStackView
}
/// Used to make changes to the View based off a change events or from local properties.
open override func updateView() {
super.updateView()
@ -140,7 +184,7 @@ open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopov
selectedDateLabel.isEnabled = isEnabled
calendarIcon.color = iconColorConfiguration.getColor(self)
}
/// Resets to default settings.
open override func reset() {
super.reset()
@ -152,32 +196,249 @@ open class DatePicker: EntryFieldBase, DatePickerViewControllerDelegate, UIPopov
formatter.dateFormat = dateFormat.format
selectedDateLabel.text = formatter.string(from: date)
}
}
internal func togglePicker() {
let calendarVC = DatePickerViewController(calendarModel, delegate: self)
calendarVC.modalPresentationStyle = .popover
calendarVC.selectedDate = selectedDate ?? Date()
if let popoverController = calendarVC.popoverPresentationController {
popoverController.delegate = self
popoverController.sourceView = containerView
popoverController.sourceRect = containerView.bounds
popoverController.permittedArrowDirections = .up
extension DatePicker {
private func showPopover() {
guard let viewController = UIApplication.topViewController(), var parentView = viewController.view, !popoverVisible else {
hidePopoverView()
return
}
if let viewController = UIApplication.topViewController() {
viewController.present(calendarVC, animated: true, completion: nil)
let calendar = CalendarBase()
calendar.activeDates = calendarModel.activeDates
calendar.hideContainerBorder = calendarModel.hideContainerBorder
calendar.hideCurrentDateIndicator = calendarModel.hideCurrentDateIndicator
calendar.inactiveDates = calendarModel.inactiveDates
calendar.indicators = calendarModel.indicators
calendar.maxDate = calendarModel.maxDate
calendar.minDate = calendarModel.minDate
calendar.surface = calendarModel.surface
calendar.setNeedsLayout()
calendar.layoutIfNeeded()
//size the popover
popoverViewSize = .init(width: calendar.frame.width, height: calendar.frame.height)
//find scrollView
if scrollView == nil {
scrollView = findScrollView(from: containerView)
scrollViewContentSize = scrollView?.contentSize
}
if let scrollView {
parentView = scrollView
}
// see if you should use the popover or show an alert
if let popoverOrigin = calculatePopoverPosition(relativeTo: containerView,
in: parentView,
size: popoverViewSize,
with: popoverSpacing) {
calendar.onChange = { [weak self] control in
guard let self else { return }
selectedDate = control.selectedDate
sendActions(for: .valueChanged)
UIAccessibility.post(notification: .layoutChanged, argument: containerView)
hidePopoverView()
}
// popoverView container
popoverView = UIView()
popoverView.backgroundColor = .clear
popoverView.frame = CGRect(x: popoverOrigin.x, y: popoverOrigin.y, width: calendar.frame.width, height: calendar.frame.height)
popoverView.alpha = 0
popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
popoverVisible = true
popoverView.addSubview(calendar)
calendar.pinToSuperView()
// add views
popoverOverlayView.isHidden = false
popupOverlayTapGesture = popoverOverlayView
.publisher(for: UITapGestureRecognizer())
.sink(receiveValue: { [weak self] gesture in
guard let self else { return }
gestureEventOccured(gesture, parentView: parentView)
})
parentView.addSubview(popoverOverlayView)
popoverOverlayView.pinToSuperView()
parentView.addSubview(popoverView)
parentView.layoutIfNeeded()
// update containerview
_ = responder?.becomeFirstResponder()
updateContainerView()
// animate the calendar to show
UIView.animate(withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.2,
options: .curveEaseOut,
animations: { [weak self] in
guard let self else { return }
popoverView.alpha = 1
popoverView.transform = CGAffineTransform.identity
UIAccessibility.post(notification: .layoutChanged, argument: calendar)
parentView.layoutIfNeeded()
})
} else {
let dialog = UIScrollView()
dialog.translatesAutoresizingMaskIntoConstraints = false
dialog.addSubview(calendar)
dialog.backgroundColor = .clear
dialog.contentSize = .init(width: calendar.frame.width + 20, height: calendar.frame.width + 20)
dialog.width(calendar.frame.width + 20)
dialog.height(calendar.frame.height + 20)
calendar.pinToSuperView(.uniform(10))
calendar.onChange = { [weak self] control in
guard let self else { return }
selectedDate = control.selectedDate
sendActions(for: .valueChanged)
UIAccessibility.post(notification: .layoutChanged, argument: containerView)
viewController.dismiss(animated: true)
}
let alert = AlertViewController().with {
$0.dialog = dialog
$0.modalPresentationStyle = .overCurrentContext
$0.modalTransitionStyle = .crossDissolve
}
topViewController = viewController
viewController.present(alert, animated: true){
dialog.flashScrollIndicators()
}
}
}
private func hidePopoverView() {
if topViewController != nil {
topViewController?.dismiss(animated: true)
topViewController = nil
} else {
popoverOverlayView.isHidden = true
popoverOverlayView.removeFromSuperview()
popupOverlayTapGesture?.cancel()
popupOverlayTapGesture = nil
UIView.animate(withDuration: 0.2,
animations: {[weak self] in
guard let self, let popoverView else { return }
popoverView.alpha = 0
popoverView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
if let scrollView, let scrollViewContentSize {
scrollView.contentSize = scrollViewContentSize
}
}) { [weak self] _ in
guard let self, let popoverView else { return }
popoverView.isHidden = true
popoverView.removeFromSuperview()
popoverVisible = false
responder?.resignFirstResponder()
setNeedsUpdate()
UIAccessibility.post(notification: .layoutChanged, argument: containerView)
}
}
}
internal func didSelectDate(_ controller: DatePickerViewController, date: Date) {
selectedDate = date
controller.dismiss(animated: true) { [weak self] in
guard let self else { return }
self.sendActions(for: .valueChanged)
UIAccessibility.post(notification: .layoutChanged, argument: self.containerView)
private func findScrollView(from view: UIView) -> UIScrollView? {
var currentView = view
while let superview = currentView.superview {
if let scrollView = superview as? UIScrollView {
return scrollView
}
currentView = superview
}
return nil
}
public func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
return .none
private func calculatePopoverPosition(relativeTo sourceView: UIView, in parentView: UIView, size: CGSize, with spacing: CGFloat) -> CGPoint? {
let sourceFrameInParent = sourceView.convert(sourceView.bounds, to: parentView)
let parentBounds = parentView.bounds
let safeAreaInsets = parentView.safeAreaInsets
let popoverWidth = size.width
let popoverHeight = size.height
var popoverX: CGFloat = 0
var popoverY: CGFloat = 0
// Calculate horizontal position
if sourceFrameInParent.width < popoverWidth {
if sourceFrameInParent.midX - popoverWidth / 2 < 0 {
// Align to left
popoverX = sourceFrameInParent.minX
} else if sourceFrameInParent.midX + popoverWidth / 2 > parentBounds.width {
// Align to right
popoverX = sourceFrameInParent.maxX - popoverWidth
} else {
// Center on source view
popoverX = sourceFrameInParent.midX - popoverWidth / 2
}
} else {
popoverX = sourceFrameInParent.midX - popoverWidth / 2
}
// Ensure the popover is within the parent's bounds horizontally
popoverX = max(0, min(popoverX, parentBounds.width - popoverWidth))
var availableSpaceAbove: CGFloat = 0.0
var availableSpaceBelow: CGFloat = 0.0
/// if the scrollView is set we want to change how we calculate the containerView's position
if let scrollView = parentView as? UIScrollView {
// Calculate vertical position and height
availableSpaceAbove = sourceFrameInParent.minY - scrollView.bounds.minY - spacing
availableSpaceBelow = scrollView.bounds.maxY - sourceFrameInParent.maxY - spacing
if availableSpaceAbove > availableSpaceBelow {
// Show above
popoverY = sourceFrameInParent.minY - popoverHeight - spacing
} else {
// Show below
popoverY = sourceFrameInParent.maxY + spacing
// See if we need to expand the contentSize of the ScrollView
let diff = scrollView.contentSize.height - sourceFrameInParent.maxY
if diff < popoverHeight {
scrollView.contentSize.height += popoverHeight - diff + VDSLayout.space4X
}
}
} else {
// Calculate vertical position and height
availableSpaceAbove = sourceFrameInParent.minY - safeAreaInsets.top - spacing
availableSpaceBelow = parentBounds.height - sourceFrameInParent.maxY - safeAreaInsets.bottom - spacing
if availableSpaceAbove >= popoverHeight {
// Show above
popoverY = sourceFrameInParent.minY - popoverHeight - spacing
} else if availableSpaceBelow >= popoverHeight {
// Show below
popoverY = sourceFrameInParent.maxY + spacing
} else {
//return nil since there is no way we can show the popover without a scrollview
return nil
}
}
return .init(x: popoverX, y: popoverY)
}
private func gestureEventOccured(_ gesture: UIGestureRecognizer, parentView: UIView) {
guard let popoverView, popoverVisible else { return }
let location = gesture.location(in: parentView)
if !popoverView.frame.contains(location) {
hidePopoverView()
}
}
}

View File

@ -1,71 +0,0 @@
//
// DatePickerPopoverViewController.swift
// VDS
//
// Created by Matt Bruce on 5/14/24.
//
import Foundation
import UIKit
protocol DatePickerViewControllerDelegate: NSObject {
func didSelectDate(_ controller: DatePicker.DatePickerViewController, date: Date)
}
extension DatePicker {
class DatePickerViewController: UIViewController {
private var padding: CGFloat = 15
private var topPadding: CGFloat { 10 + padding }
private var calendarModel: CalendarModel
private let picker = CalendarBase()
weak var delegate: DatePickerViewControllerDelegate?
init(_ calendarModel: CalendarModel, delegate: DatePickerViewControllerDelegate?) {
self.delegate = delegate
self.calendarModel = calendarModel
super.init(nibName: nil, bundle: nil)
self.picker.onChange = { [weak self] control in
guard let self else { return }
self.delegate?.didSelectDate(self, date: control.selectedDate)
}
}
var selectedDate: Date = Date() {
didSet {
picker.selectedDate = selectedDate
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(picker)
picker.surface = calendarModel.surface
picker.hideContainerBorder = calendarModel.hideContainerBorder
picker.hideCurrentDateIndicator = calendarModel.hideCurrentDateIndicator
picker.indicators = calendarModel.indicators
picker.activeDates = calendarModel.activeDates
picker.inactiveDates = calendarModel.inactiveDates
picker.selectedDate = selectedDate
picker.minDate = calendarModel.minDate
picker.maxDate = calendarModel.maxDate
picker.pinToSuperView(.init(top: topPadding, left: padding, bottom: padding, right: padding))
view.backgroundColor = picker.backgroundColor
}
override var preferredContentSize: CGSize {
get {
var size = picker.frame.size
size.height += 40
size.width += 30
return size
}
set {
super.preferredContentSize = newValue
}
}
}
}

View File

@ -30,19 +30,7 @@ open class DropdownSelect: EntryFieldBase {
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
/// Override UIControl state to add the .error state if showSuccess is true and if showError is true.
open override var state: UIControl.State {
get {
var state = super.state
if dropdownField.isFirstResponder {
state.insert(.focused)
}
return state
}
}
//--------------------------------------------------
/// If true, the label will be displayed inside the dropdown containerView. Otherwise, the label will be above the dropdown containerView like a normal text input.
open var showInlineLabel: Bool = false { didSet { setNeedsUpdate() }}

View File

@ -389,60 +389,100 @@ open class Label: UILabel, ViewProtocol, UserInfoable {
}
}
//--------------------------------------------------
// MARK: - Touch Events
//--------------------------------------------------
@objc private func textLinkTapped(_ gesture: UITapGestureRecognizer) {
for actionable in actions {
// This determines if we tapped on the desired range of text.
let location = gesture.location(in: self)
if didTapActionInLabel(location, inRange: actionable.range) {
actionable.performAction()
return
}
let location = gesture.location(in: self)
if let action = actions.first(where: { isAction(for: location, inRange: $0.range) }) {
action.performAction()
}
}
public func isAction(for location: CGPoint) -> Bool {
for actionable in actions {
if didTapActionInLabel(location, inRange: actionable.range) {
return true
}
}
return false
actions.contains(where: {isAction(for: location, inRange: $0.range)})
}
private func didTapActionInLabel(_ location: CGPoint, inRange targetRange: NSRange) -> Bool {
public func isAction(for location: CGPoint, inRange targetRange: NSRange) -> Bool {
guard let abstractContainer = abstractTextContainer() else { return false }
let textContainer = abstractContainer.textContainer
let layoutManager = abstractContainer.layoutManager
guard let attributedText else { return false }
let indexOfGlyph = layoutManager.glyphIndex(for: location, in: textContainer)
let intrinsicWidth = intrinsicContentSize.width
// Assert that tapped occured within acceptable bounds based on alignment.
switch textAlignment {
case .right:
if location.x < bounds.width - intrinsicWidth {
return false
}
case .center:
let halfBounds = bounds.width / 2
let halfIntrinsicWidth = intrinsicWidth / 2
if location.x > halfBounds + halfIntrinsicWidth {
return false
} else if location.x < halfBounds - halfIntrinsicWidth {
return false
}
default: // Left align
if location.x > intrinsicWidth {
return false
}
}
// Affirms that the tap occured in the desired rect of provided by the target range.
return layoutManager.boundingRect(forGlyphRange: targetRange, in: textContainer).contains(location)
&& NSLocationInRange(indexOfGlyph, targetRange)
}
/**
Provides a text container and layout manager of how the text would appear on screen.
They are used in tandem to derive low-level TextKit results of the label.
*/
public func abstractTextContainer() -> (textContainer: NSTextContainer, layoutManager: NSLayoutManager, textStorage: NSTextStorage)? {
// Must configure the attributed string to translate what would appear on screen to accurately analyze.
guard let attributedText = attributedText else { return nil }
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = textAlignment
let stagedAttributedString = NSMutableAttributedString(attributedString: attributedText)
stagedAttributedString.addAttributes([NSAttributedString.Key.paragraphStyle: paragraph], range: NSRange(location: 0, length: attributedText.string.count))
let textStorage = NSTextStorage(attributedString: stagedAttributedString)
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: bounds.size)
let textStorage = NSTextStorage(attributedString: attributedText)
let textContainer = NSTextContainer(size: .zero)
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
guard let _ = attributedText.attribute(NSAttributedString.Key.action, at: characterIndex, effectiveRange: nil) as? String, characterIndex < attributedText.length else { return false }
return true
textContainer.lineFragmentPadding = 0.0
textContainer.lineBreakMode = lineBreakMode
textContainer.maximumNumberOfLines = numberOfLines
textContainer.size = bounds.size
return (textContainer, layoutManager, textStorage)
}
//--------------------------------------------------
// MARK: - Accessibility
//--------------------------------------------------
private func customAccessibilityElement(text: String?, range: NSRange, accessibleText: String? = nil) -> AccessibilityActionElement? {
guard let text = text, let attributedText else { return nil }
guard let text = text, let abstractContainer = abstractTextContainer() else { return nil }
let textContainer = abstractContainer.textContainer
let layoutManager = abstractContainer.layoutManager
let actionText = accessibleText ?? (text.isValid(range: range) ? NSString(string:text).substring(with: range) : text)
// Calculate the frame of the substring
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: bounds.size)
let textStorage = NSTextStorage(attributedString: attributedText)
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
var glyphRange = NSRange()
// Convert the range for the substring into a range of glyphs
layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange)
let substringBounds = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
// Create custom accessibility element
@ -456,10 +496,6 @@ open class Label: UILabel, ViewProtocol, UserInfoable {
return element
}
//--------------------------------------------------
// MARK: - Accessibility
//--------------------------------------------------
open var accessibilityAction: ((Label) -> Void)?
private var _isAccessibilityElement: Bool = false

View File

@ -183,7 +183,7 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable {
open var statusIcon: Icon = Icon().with {
$0.size = .medium
$0.isAccessibilityElement = false
$0.isAccessibilityElement = true
}
open var labelText: String? { didSet { setNeedsUpdate() } }
@ -207,6 +207,9 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable {
if isReadOnly {
state.insert(.readonly)
}
if let responder, responder.isFirstResponder {
state.insert(.focused)
}
}
return state
}
@ -341,6 +344,11 @@ open class EntryFieldBase: Control, Changeable, FormFieldInternalValidatable {
guard let self else { return "" }
return value
}
statusIcon.bridge_accessibilityLabelBlock = { [weak self] in
guard let self else { return "" }
return showError || hasInternalError ? "error" : nil
}
}
/// Updates the UI

View File

@ -125,7 +125,7 @@ extension InputField {
class CreditCardHandler: FieldTypeHandler {
static let shared = CreditCardHandler()
private override init() {
super.init()
self.validateOnChange = false
@ -135,6 +135,7 @@ extension InputField {
fileprivate func updateLeftImage(_ inputField: InputField) {
let imageName = inputField.cardType.imageName(surface: inputField.surface)
creditCardImageView.image = BundleManager.shared.image(for: imageName)
creditCardImageView.accessibilityLabel = inputField.cardType.rawValue
}
override func updateView(_ inputField: InputField) {
@ -148,14 +149,14 @@ extension InputField {
inputField.textField.leftView = iconContainerView
inputField.textField.leftViewMode = .always
updateLeftImage(inputField)
}
internal var creditCardImageView = UIImageView().with {
$0.height(20)
$0.width(32)
$0.isAccessibilityElement = false
$0.isAccessibilityElement = true
$0.translatesAutoresizingMaskIntoConstraints = false
$0.contentMode = .scaleAspectFill
$0.clipsToBounds = true

View File

@ -166,11 +166,7 @@ open class InputField: EntryFieldBase {
if showSuccess {
state.insert(.success)
}
if textField.isFirstResponder {
state.insert(.focused)
}
return state
}
}
@ -200,6 +196,54 @@ open class InputField: EntryFieldBase {
borderColorConfiguration.setSurfaceColors(VDSColor.feedbackSuccessOnlight, VDSColor.feedbackSuccessOndark, forState: .success)
textField.textColorConfiguration = textFieldTextColorConfiguration
containerView.bridge_accessibilityLabelBlock = { [weak self] in
guard let self else { return "" }
var accessibilityLabels = [String]()
if let text = titleLabel.text?.trimmingCharacters(in: .whitespaces) {
accessibilityLabels.append(text)
}
if let formatText = textField.formatText, !formatText.isEmpty {
accessibilityLabels.append("format, \(formatText)")
}
if let placeholderText = textField.placeholder, !placeholderText.isEmpty {
accessibilityLabels.append("placeholder, \(placeholderText)")
}
if isReadOnly {
accessibilityLabels.append("read only")
}
if !isEnabled {
accessibilityLabels.append("dimmed")
}
if let errorText, showError {
accessibilityLabels.append("error, \(errorText)")
}
if let successText, showSuccess {
accessibilityLabels.append("success, \(successText)")
}
accessibilityLabels.append("\(Self.self)")
return accessibilityLabels.joined(separator: ", ")
}
statusIcon.bridge_accessibilityLabelBlock = { [weak self] in
guard let self else { return "" }
if showError {
return "error"
} else if showSuccess {
return "success"
} else {
return nil
}
}
}
open override func getFieldContainer() -> UIView {
@ -264,11 +308,20 @@ open class InputField: EntryFieldBase {
get {
var elements = [Any]()
elements.append(contentsOf: [titleLabel, containerView])
if showError {
if let leftView = textField.leftView {
elements.append(leftView)
}
if !statusIcon.isHidden{
elements.append(statusIcon)
if let errorText, !errorText.isEmpty {
elements.append(errorLabel)
}
}
if !actionTextLink.isHidden {
elements.append(actionTextLink)
}
if let errorText, !errorText.isEmpty, showError || hasInternalError {
elements.append(errorLabel)
} else if showSuccess, let successText, !successText.isEmpty {
elements.append(successLabel)
}

View File

@ -56,18 +56,7 @@ open class TextArea: EntryFieldBase {
//--------------------------------------------------
// MARK: - Public Properties
//--------------------------------------------------
/// Override UIControl state to add the .error state if showSuccess is true and if showError is true.
open override var state: UIControl.State {
get {
var state = super.state
if textView.isFirstResponder {
state.insert(.focused)
}
return state
}
}
//--------------------------------------------------
override var containerSize: CGSize { CGSize(width: 182, height: Height.twoX.value) }
/// Enum used to describe the the height of TextArea.

View File

@ -12,23 +12,11 @@ extension UITapGestureRecognizer {
/// Determines if the touch event has a action attribute within the range given
/// - Parameters:
/// - label: UILabel in question
/// - label: Label in question
/// - targetRange: Range to look within
/// - Returns: Wether the range in the label has an action
public func didTapActionInLabel(_ label: UILabel, inRange targetRange: NSRange) -> Bool {
guard let attributedText = label.attributedText else { return false }
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: label.bounds.size)
let textStorage = NSTextStorage(attributedString: attributedText)
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
let location = location(in: label)
let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
guard let _ = attributedText.attribute(NSAttributedString.Key.action, at: characterIndex, effectiveRange: nil) as? String, characterIndex < attributedText.length else { return false }
return true
public func didTapActionInLabel(_ label: Label, inRange targetRange: NSRange) -> Bool {
let tapLocation = location(in: label)
return label.isAction(for: tapLocation, inRange: targetRange)
}
}

View File

@ -1,6 +1,15 @@
1.0.68
1.0.69
----------------
- DatePicker - Refactored how this is shown
- Checkbox Item/Group - Accessibility Refactor
- Radiobox Item/Group - Accessibility Refactor
- Radiobutton Item/Group - Accessibility Refactor
- CXTDT-553663 - DropdownSelect - Accessibility - has popup
- CXTDT-577463 - InputField - Accessibility
1.0.69
----------------
- Expired Build because of a issue
1.0.67
----------------