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

IOS-9356 Adjust crouton constraints above the tabbar or the safe area #422

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 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
83 changes: 41 additions & 42 deletions Sources/Mistica/Components/Crouton/Presentation/CroutonView.swift
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@L-Trujillo26 I have a doubt, where the crouton is fixed at the end? I mean the view is added to the current view and therefore disappears if we navigate to another site or on the other hand is fixed to the root view of the app and therefore the crouton is fixed even after browsing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a exact UIViewController isn't specified, the crouton will be add to the RootViewController making it visible during navigation. Its persistence depends on the configuration set in the initialization. This behavior has been confirmed with the design team.

Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ class CroutonView: UIView {
static let contentAnimationDuration = presentationAnimationDuration - contentAnimationDelay

static let margins = NSDirectionalEdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)
static let marginsWhenUsingSafeArea = NSDirectionalEdgeInsets(top: 14, leading: 16, bottom: 0, trailing: 16)

static let buttonWidthThresholdForVerticalLayout: CGFloat = 128
static let horizontalSpacing: CGFloat = 16
static let verticalSpacing: CGFloat = 18

static let closeButtonWidthAndHeight: CGFloat = 20
static let containerMargin: CGFloat = 8
}

public typealias DismissHandlerBlock = (SnackbarDismissReason) -> Void
Expand Down Expand Up @@ -87,7 +87,7 @@ class CroutonView: UIView {
let closeImageView = IntrinsictImageView()
closeImageView.intrinsicHeight = Constants.closeButtonWidthAndHeight
closeImageView.intrinsicWidth = Constants.closeButtonWidthAndHeight
closeImageView.image = UIImage.closeButtonBlackSmallIcon.withRenderingMode(.alwaysTemplate)
closeImageView.image = UIImage.regularCloseButtonIcon.withRenderingMode(.alwaysTemplate)
closeImageView.tintColor = .inverse

let tapGesture = UITapGestureRecognizer()
Expand All @@ -98,9 +98,6 @@ class CroutonView: UIView {
return closeImageView
}()

// A dummy view used for skipping the bottom safe area inset
private lazy var dummyView = UIView()

private var timer: Timer?

private let text: String
Expand Down Expand Up @@ -158,6 +155,11 @@ class CroutonView: UIView {

adjustStackViewLayout(traitCollection: traitCollection)
}

override public func layoutSublayers(of layer: CALayer) {
super.layoutSublayers(of: layer)
setMisticaRadius(.popup)
}
}

// MARK: Internal methods
Expand All @@ -176,14 +178,13 @@ extension CroutonView {

addContainerConstraints(to: container)

transform = CGAffineTransform(translationX: 0, y: frameHeight)

alpha = 0
UIView.animate(
withDuration: Constants.presentationAnimationDuration,
delay: 0,
delay: Constants.contentAnimationDelay,
options: .curveEaseOut,
animations: {
self.transform = .identity
self.alpha = 1
},
completion: { _ in
AccessibilityHelper.post(self.text)
Expand Down Expand Up @@ -211,14 +212,12 @@ extension CroutonView {
let previousClipsToBounds = superview.clipsToBounds
superview.clipsToBounds = true

transform = .identity

UIView.animate(
withDuration: Constants.presentationAnimationDuration,
delay: 0,
delay: Constants.contentAnimationDelay,
options: .curveEaseIn,
animations: {
self.transform = CGAffineTransform(translationX: 0, y: self.frameHeight)
self.alpha = 0
},
completion: { _ in
superview.clipsToBounds = previousClipsToBounds
Expand All @@ -242,53 +241,53 @@ private extension CroutonView {

func layoutViews() {
addSubview(verticalStackView)
addSubview(dummyView)

verticalStackView.translatesAutoresizingMaskIntoConstraints = false
dummyView.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
verticalStackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
verticalStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
verticalStackView.bottomAnchor.constraint(equalTo: dummyView.topAnchor),
verticalStackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),

dummyView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
dummyView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
dummyView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor)
verticalStackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)
])
}

func addContainerConstraints(to container: UIView) {
translatesAutoresizingMaskIntoConstraints = false
directionalLayoutMargins = Constants.margins

if container.safeAreaInsets.bottom > 0 {
NSLayoutConstraint.activate([
trailingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.trailingAnchor),
leadingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.leadingAnchor),
bottomAnchor.constraint(equalTo: container.safeAreaLayoutGuide.bottomAnchor, constant: container.safeAreaInsets.bottom),
dummyView.heightAnchor.constraint(equalToConstant: container.safeAreaInsets.bottom)
])
let tabBar = findTabBar(in: container)

directionalLayoutMargins = Constants.marginsWhenUsingSafeArea
} else {
let bottomConstraint: NSLayoutConstraint
var bottomConstraint = bottomAnchor.constraint(equalTo: container.safeAreaLayoutGuide.bottomAnchor, constant: -Constants.containerMargin)

if let scrollViewContainer = container as? UIScrollView {
// The bottomAnchor does not work in scrollViews, as workarround we take the topAnchor as reference
bottomConstraint = bottomAnchor.constraint(equalTo: container.topAnchor, constant: scrollViewContainer.frameHeight)
} else {
bottomConstraint = bottomAnchor.constraint(equalTo: container.bottomAnchor)
}
if let tabBar = tabBar, !tabBar.isHidden {
bottomConstraint = bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -(tabBar.bounds.height + Constants.containerMargin))
} else if let scrollView = container as? UIScrollView {
// The bottomAnchor does not work in scrollViews, as workarround we take the topAnchor as reference
bottomConstraint = bottomAnchor.constraint(equalTo: container.topAnchor, constant: scrollView.frameHeight - Constants.containerMargin)
}

NSLayoutConstraint.activate([
trailingAnchor.constraint(equalTo: container.trailingAnchor),
leadingAnchor.constraint(equalTo: container.leadingAnchor),
bottomConstraint
])
let constraints = [
trailingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.trailingAnchor, constant: -Constants.containerMargin),
leadingAnchor.constraint(equalTo: container.safeAreaLayoutGuide.leadingAnchor, constant: Constants.containerMargin),
bottomConstraint
]
NSLayoutConstraint.activate(constraints)
}

directionalLayoutMargins = Constants.margins
private func findTabBar(in view: UIView) -> UITabBar? {
for subview in view.subviews {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid of finding the new tabBar in iPad...
image

https://developer.apple.com/documentation/uikit/elevating-your-ipad-app-with-a-tab-bar-and-sidebar

Could we check if it's a bottom tabBar?

Copy link
Contributor Author

@L-Trujillo26 L-Trujillo26 Feb 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s possible to implement a logic to check if a tabBar exists and if it is positioned at the bottom of the device. If this condition isn't met, it would continue searching. I am going to add this logic to prevent cases like this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done and added tests

if let tabBar = subview as? UITabBar {
// Checks if the tabBar is in the bottom
if let superview = tabBar.superview,
tabBar.frame.origin.y >= superview.bounds.height - tabBar.frame.height {
return tabBar
}
} else if let foundTabBar = findTabBar(in: subview) {
return foundTabBar
}
}
return nil
}

func addCountdownToDismiss() {
Expand Down
4 changes: 4 additions & 0 deletions Sources/MisticaCommon/Assets/AssetToolkit+UIImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,8 @@ public extension UIImage {
static var bullet: UIImage {
UIImage(named: "icn_bullet", type: .common)!
}

static var regularCloseButtonIcon: UIImage {
UIImage(named: "icn_close_regular", type: .common)!
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "close-regular.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
132 changes: 115 additions & 17 deletions Tests/MisticaTests/UI/CroutonTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,23 +81,101 @@ final class CroutonTests: XCTestCase {
)
)
}

func testInfoCroutonWithBottomTabbar() {
MisticaConfig.styleControls([.tabBar])
assertSnapshot(
for: [BrandStyle.movistar],
and: [.light],
Comment on lines +88 to +89
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

as: .image(on: .iPhoneSe),
viewBuilder: makeCroutonWithBottomTabBar(
withText: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
style: .info
)
)
}

func testInfoCroutonWithTopTabbar() {
MisticaConfig.styleControls([.tabBar])
assertSnapshot(
for: [BrandStyle.movistar],
and: [.light],
as: .image(on: .iPhoneSe),
viewBuilder: makeCroutonWithTopTabBar(
withText: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
style: .info
)
)
}

func testInfoCroutonWithScrollView() {
assertSnapshot(
for: [BrandStyle.movistar],
and: [.light],
as: .image(on: .iPhoneSe),
viewBuilder: makeCroutonWithScrollView(
withText: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
style: .info
)
)
}
}

private extension CroutonTests {
func makeCrouton(withText text: String, actionTitle: String? = nil, style: CroutonStyle) -> UIViewController {
let viewController = CroutonTestViewController(
let croutonViewController = CroutonTestViewController(
text: text,
action: actionTitle.map { ($0, $0, {}) },
style: style
)
return croutonViewController
}

func makeCroutonWithTopTabBar(withText text: String, actionTitle: String? = nil, style: CroutonStyle) -> UIViewController {
let viewController = makeCrouton(withText: text, actionTitle: actionTitle, style: style)
addTabBar(to: viewController, isTop: true)
return viewController
}

func makeCroutonWithBottomTabBar(withText text: String, actionTitle: String? = nil, style: CroutonStyle) -> UIViewController {
let viewController = makeCrouton(withText: text, actionTitle: actionTitle, style: style)
addTabBar(to: viewController, isTop: false)
return viewController
}

func makeCroutonWithScrollView(withText text: String, actionTitle: String? = nil, style: CroutonStyle) -> UIViewController {
ScrollViewCroutonViewController(text: text, action: actionTitle.map { ($0, $0, {}) }, style: style)
}

private func addTabBar(to viewController: UIViewController, isTop: Bool) {
let tabBar = UITabBar()
tabBar.translatesAutoresizingMaskIntoConstraints = false
viewController.view.addSubview(tabBar)

let topConstraint: NSLayoutConstraint
if isTop {
topConstraint = tabBar.topAnchor.constraint(equalTo: viewController.view.safeAreaLayoutGuide.topAnchor)
} else {
topConstraint = tabBar.bottomAnchor.constraint(equalTo: viewController.view.safeAreaLayoutGuide.bottomAnchor)
}

NSLayoutConstraint.activate([
topConstraint,
tabBar.leadingAnchor.constraint(equalTo: viewController.view.leadingAnchor),
tabBar.trailingAnchor.constraint(equalTo: viewController.view.trailingAnchor),
tabBar.heightAnchor.constraint(equalToConstant: 50)
])
let tabIcon = UIImage(systemName: "house.fill")
let tabItem1 = UITabBarItem(title: isTop ? "TopTab_1" : "BottomTab_1", image: tabIcon, selectedImage: tabIcon)
let tabItem2 = UITabBarItem(title: isTop ? "TopTab_2" : "BottomTab_2", image: tabIcon, selectedImage: tabIcon)
tabBar.items = [tabItem1, tabItem2]
}
}

private class CroutonTestViewController: UIViewController {
private let text: String
private let action: CroutonController.ActionConfig?
private let style: CroutonStyle
private class BaseCroutonViewController: UIViewController {
let text: String
let action: CroutonController.ActionConfig?
let style: CroutonStyle

init(text: String, action: CroutonController.ActionConfig?, style: CroutonStyle) {
self.text = text
Expand All @@ -111,6 +189,12 @@ private class CroutonTestViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}

var dismissInterval: SnackbarDismissInterval {
action.map { .tenSeconds(SnackbarAction(title: $0.text, handler: $0.handler)) } ?? .fiveSeconds
}
}

private class CroutonTestViewController: BaseCroutonViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .background
Expand All @@ -119,25 +203,39 @@ private class CroutonTestViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

let config = SnackbarConfig(
title: text,
dismissInterval: dismissInterval
)

CroutonController().showCrouton(
config: config,
config: SnackbarConfig(title: text, dismissInterval: dismissInterval),
style: style,
rootViewController: { self }
)
}
}

private extension CroutonTestViewController {
var dismissInterval: SnackbarDismissInterval {
guard let action = action else {
return .fiveSeconds
}
private class ScrollViewCroutonViewController: BaseCroutonViewController, CustomCroutonContainer {
var customCroutonContainerView: UIView { scrollView }
private let scrollView = UIScrollView()

override func viewDidLoad() {
super.viewDidLoad()
setupScrollView()

CroutonController.shared.showCrouton(
config: SnackbarConfig(title: text, dismissInterval: dismissInterval),
style: style,
rootViewController: { self }
)
}

return .tenSeconds(SnackbarAction(title: action.text, handler: action.handler))
private func setupScrollView() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.backgroundColor = .lightGray
view.addSubview(scrollView)

NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.heightAnchor.constraint(equalToConstant: 250)
])
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading