-
Notifications
You must be signed in to change notification settings - Fork 5
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
base: main
Are you sure you want to change the base?
Changes from 17 commits
a48024a
0997b66
edcd59f
18bf440
5f1e920
523f4f4
58ded75
65f9e4e
0f63ba7
dbf17ee
8216d64
2e9e867
c6a2ba0
a553cdd
b348cad
e5b054d
5d5c675
5dbe6fc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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() | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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) | ||
|
@@ -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 | ||
|
@@ -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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm afraid of finding the new tabBar in iPad... 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() { | ||
|
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 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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 | ||
|
@@ -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) | ||
]) | ||
} | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.