Updating Constraints programmatically? - swift

I have a subclass of UIView, I want to manipulate a constraint but it does not work.
When pressing a button exchangesViewActivated changes and the functions get called.
var exchangesViewActivated = false {
didSet {
if exchangesViewActivated == true {
setExchangesView()
} else {
setUpLayout()
}
}
}
Die subview and translatesAutoresizingMaskIntoConstraints are set.
func setUpLayout() {
bottomContainer.heightAnchor.constraint(equalToConstant: 500).isActive = true
bottomContainer.leadingAnchor.constraint(equalTo: scrollViewContrainer.leadingAnchor).isActive = true
bottomContainer.trailingAnchor.constraint(equalTo: scrollViewContrainer.trailingAnchor).isActive = true
bottomContainer.topAnchor.constraint(equalTo: configBar.bottomAnchor).isActive = true
bottomContainer.bottomAnchor.constraint(equalTo: scrollViewContrainer.bottomAnchor).isActive = true
}
Now I want to manipulate the constraint by calling this function:
func setExchangesView() {
bottomContainer.bottomAnchor.constraint(equalTo: scrollViewContrainer.bottomAnchor).isActive = false
bottomContainer.heightAnchor.constraint(equalToConstant: 500).isActive = false
bottomContainer.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: 0).isActive = true
}
But this constraint stays activated:
bottomContainer.heightAnchor.constraint(equalToConstant: 500).isActive
Am I missing something? Is it not enough to set a constraint to false to deactivate it? Do I have to call something else?

setUpLayout() is adding new constraints every time you call setUpLayout() method. You must save constraint reference and update it next time.
For example.
// class variable
var bottomConstraint:NSLayoutConstraint? = nil
bottomConstraint = bottomContainer.heightAnchor.constraint(equalToConstant: 500)
bottomConstraint.isActive = true
And later update constraint
bottomConstraint.constant = 100
bottomConstraint.isActive = true

This doesn't deactivate old constraints as it deactivates the newly created ones so do
var heightCon:NSLayoutConstraint!
var botCon:NSLayoutConstraint!
//
heightCon = bottomContainer.heightAnchor.constraint(equalToConstant: 500)
heightCon.isActive = true
botCon = bottomContainer.bottomAnchor.constraint(equalTo: scrollViewContrainer.bottomAnchor)
botCon.isActive = true
Then you can easily reference the constraints and deactivates them and add the new constraints

Related

Swift: button added within UIView not clickable

I have the following container view:
class NotificationsContainer: UIView {
init() {
super.init(frame: .zero)
controller.view.translatesAutoresizingMaskIntoConstraints = false
addSubview(controller.view)
controller.view.isHidden = true
self.isUserInteractionEnabled = true
self.clipsToBounds = false
configureAutoLayout()
}
var showNotifications = false {
didSet {
if showNotifications == true {
controller.view.isHidden = false
} else {
controller.view.isHidden = true
}
}
}
internal lazy var notificationBanner: AlertView = {
let banner = AlertView()
banner.attrString = UploadNotificationManager.shared.notificationBannerText()
banner.alertType = .notification
banner.translatesAutoresizingMaskIntoConstraints = false
addSubview(banner)
banner.isUserInteractionEnabled = true
banner.showMeButton.addTarget(self, action: #selector(showHideNotifications), for: .touchDown)
return banner
}()
#objc func showHideNotifications() {
showNotifications = showNotifications == false ? true : false
}
private lazy var notificationView: NotificationContentView = {
let notificationView = NotificationContentView()
return notificationView
}()
private lazy var controller: UIHostingController = {
return UIHostingController(rootView: notificationView)
}()
private func configureAutoLayout() {
NSLayoutConstraint.activate([
notificationBanner.leadingAnchor.constraint(equalTo: leadingAnchor),
notificationBanner.trailingAnchor.constraint(equalTo: trailingAnchor),
controller.view.trailingAnchor.constraint(equalTo: notificationBanner.trailingAnchor),
controller.view.topAnchor.constraint(equalTo: notificationBanner.bottomAnchor)
])
}
}
AlertView contains a button as follows:
internal lazy var showMeButton: UIButton = {
let button = UIButton()
button.setTitle("Show me...", for: .normal)
button.setTitleColor(UIColor.i6.blue, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: Constants.fontSize)
addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
Then I add the container view to my main view:
private lazy var notifications: NotificationsContainer = {
let notifications = NotificationsContainer()
notifications.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(notifications)
notifications.leadingAnchor.constraint(equalTo: flightNumber.leadingAnchor).isActive = true
notifications.trailingAnchor.constraint(equalTo: flightNumber.trailingAnchor).isActive = true
return notifications
}()
override public func viewDidLoad() {
super.viewDidLoad()
stackView.insert(arrangedSubview: notifications, atIndex: 0)
}
Now as you can see I am trying to add an action to the showMeButton. However, when I click on the button, it does nothing. I have read before that this could be to do with the frame of the container view. However, I have tried setting the height of the notification view in my main view (width should already be there due to leading and trailing constraints) and I have tried setting the height of notificationBanner as well but nothing is working.
Here is the view in the view debugger:
The showMe button does not appear to be obscured and all other views appear to have dimensions...
Look at the debug view hierarchy in Xcode and see if the view containing the button is actually showing up. You haven't set enough constraints on any of these views so the height and width look like they could be ambiguous to me. Once you're inside the view debugger, another common problem is that another invisible view is covering up the one with the button and intercepting the touch gestures.

Updating constraints when device orientation change

I want to change a button height constraint according to device orientation. I am creating with height constraint. And then I am setting height constraint 60 for landscape mode, 40 for portrait mode. But when I change device orientation, height is not becoming bigger. Where is the problem. Here is my code
lazy var nextEpisodeButton: CustomPlayerButton = {
let nextEpisode = CustomPlayerButton(type: .nextEpisode, backgroundImage: nil)
nextEpisode.addTarget(self, action: #selector(nextEpisodeTapped), for: .touchUpInside)
nextEpisode.translatesAutoresizingMaskIntoConstraints = false
nextEpisode.adjustsImageWhenHighlighted = false
return nextEpisode
}()
func addNextEpisodeButton() {
view.addSubview(nextEpisodeButton)
NSLayoutConstraint.activate([
nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40),
nextEpisodeButton.widthAnchor.constraint(equalToConstant: 120),
nextEpisodeButton.rightAnchor.constraint(equalTo: view.safeRightAnchor, constant: -20),
nextEpisodeButton.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, constant: -60)
])
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.willTransition(to: size, with: coordinator)
if UIDevice.current.orientation.isLandscape {
nextEpisodeButton.heightAnchor.constraint(equalToConstant: 60).isActive = true
} else {
nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true
}
nextEpisodeButton.layoutIfNeeded()
}
You should have a reference to nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40) constraint somewhere in your ViewController and in willTransition callback just change its constant value. With your code you are creating and activating a new constraint every time you rotate rather than changing the existing one.
The constraints are persistent. When you are creating a second constraint for the same attribute (in your case the height of a view) and you are activating it, the 2 constraints are conflicting with each other.
So, you need to keep a reference for both of them and activate/deactivate them accordingly:
lazy var nextEpisodeButton: CustomPlayerButton = {
let nextEpisode = CustomPlayerButton(type: .nextEpisode, backgroundImage: nil)
nextEpisode.addTarget(self, action: #selector(nextEpisodeTapped), for: .touchUpInside)
nextEpisode.translatesAutoresizingMaskIntoConstraints = false
nextEpisode.adjustsImageWhenHighlighted = false
return nextEpisode
}()
private var buttonLandscapeHeightContraint: NSLayoutConstraint?
private var buttonPortraitHeightContraint: NSLayoutConstraint?
func addNextEpisodeButton() {
view.addSubview(nextEpisodeButton)
buttonLandscapeHeightContraint = nextEpisodeButton.heightAnchor.constraint(equalToConstant: 60)
buttonPortraitHeightContraint = nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40)
NSLayoutConstraint.activate([
nextEpisodeButton.widthAnchor.constraint(equalToConstant: 120),
nextEpisodeButton.rightAnchor.constraint(equalTo: view.safeRightAnchor, constant: -20),
nextEpisodeButton.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, constant: -60)
])
updateButtonHeightConstraint()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.willTransition(to: size, with: coordinator)
updateButtonHeightConstraint()
nextEpisodeButton.layoutIfNeeded()
}
private func updateButtonHeightConstraint() {
if UIDevice.current.orientation.isLandscape {
buttonPortraitHeightContraint?.isActive = false
buttonLandscapeHeightContraint?.isActive = true
} else {
buttonLandscapeHeightContraint?.isActive = false
buttonPortraitHeightContraint?.isActive = true
}
}
My way is to do two things:
Keep the constraints you wish to change in arrays to be activated and deactivated.
Detect device orientation through a means other than traits.
Let's start with the first. And you are off to a good start - the constraints are (a) in code and (b) using anchors. (If by chance you are using IB - Storyboards for others - you'll need to set the changing constraints as #IBOutlets.)
It looks like you are wanting this button to be in the right bottom, so let's make those constraints active:
nextEpisodeButton.rightAnchor.constraint(equalTo: view.safeRightAnchor, constant: -20).isActive = true
nextEpisodeButton.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, constant: -60).isActive = true
This will pin things properly no matter what the orientation.
Now, let's say you want to change size. You need to put these into two arrays:
var portraitLayout = [NSLayoutConstraint]()
var landscapeLayout = [NSLayoutConstraint]()
portraitLayout.append(nextEpisodeButton.widthAnchor.constraint(equalToConstant: 120))
portraitLayout.append(nextEpisodeButton.heightAnchor.constraint(equalToConstant: 40))
landscapeLayout.append(nextEpisodeButton.widthAnchor.constraint(equalToConstant: 120))
landscapeLayout.append(nextEpisodeButton.heightAnchor.constraint(equalToConstant: 60))
This sets up a 40x120 button in portrait and a 60x120 button in landscape. This can (should?) be done in viewDidLoad. Now it's time to activate/deactivate....
Only one array should be active, and you'll need to do one at the time the view is initialized. I'll get to that, but first, let me show two lines of code that is necessary:
NSLayoutConstraint.deactivate(landscapeLayout)
NSLayoutConstraint.activate(portraitLayout)
You can try to add/delete constraints, but this is not only risky and not as easy to maintain, it's not even needed. Simply set all constraints the are constant as isActive = true, put the ones that change into arrays, and activate/deactive.
(If you want to animate such changes - I wouldn't for you - then do this and add UIView.animate(withDuration:) at the end.)
Now, the rough piece, detecting the orientation.
Apple decided to add trait collections a few years ago. They work well (and this year I finally get why they did it the way they did). But they have one serious issue - iPads in full screen mode always have a normal size. (I'm writing an iPad only app this year and in split screen mode it may be compact.)
Your question stressed orientation, so I'd recommend not use trait collection changes. Instead, use viewWillLayoutSubviews. For me, this seems to be more reliable - it's the earliest in the view controller lifecycle that I've found. You'll need to do two things... set the initial orientation and detect changes.
Here's my setup. In a UIView extension:
public func orientationHasChanged(_ isInPortrait:inout Bool) -> Bool {
if self.frame.width > self.frame.height {
if isInPortrait {
isInPortrait = false
return true
}
} else {
if !isInPortrait {
isInPortrait = true
return true
}
}
return false
}
public func setOrientation(_ p:[NSLayoutConstraint], _ l:[NSLayoutConstraint]) {
NSLayoutConstraint.deactivate(l)
NSLayoutConstraint.deactivate(p)
if self.bounds.width > self.bounds.height {
NSLayoutConstraint.activate(l)
} else {
NSLayoutConstraint.activate(p)
}
}
p and l are portrait and landscape respectively. All I do is simply check the bounds and active/deactive appropriately.
override func viewWillLayoutSubviews() {
super.viewDidLayoutSubviews()
if initialOrientation {
initialOrientation = false
if view.frame.width > view.frame.height {
isInPortrait = false
} else {
isInPortrait = true
}
view.setOrientation(p, l)
} else {
if view.orientationHasChanged(&isInPortrait) {
view.setOrientation(p, l)
}
}
}
This is likely overkill for your needs. I'm basically tracking two things (initial and current orientation) for changes and calling things when needed - viewWillLayoutSubviews can be called more than once during a load(?) and for other reasons than an orientation change.
Conclusion:
You are close, but you have a few changes. First, set your constant constraints to isActive = true and activate/deactivate the remaining ones as arrays. Second, unless your app is iPhone only (and even then it will still be available for iPad) do not use trait collections, but instead use the view controller lifecycle and the screen bounds.

Why is my UIView not displaying correctly when updating autolayout constraints?

I am trying to animate a simple autolayout constraint change, but when I call it the first time it animates but instead of just stretching and changing the height, it moves the whole view up, if I call it again it then fixes itself.
Here is how I set up the constraints:
hiddenView.addSubview(topView)
topView.translatesAutoresizingMaskIntoConstraints = false
topView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
topView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
topView.bottomAnchor.constraint(equalTo: hiddenView.bottomAnchor).isActive = true
topViewDefaultTopAnchorConstraints.append(topView.topAnchor.constraint(equalTo: hiddenView.centerYAnchor))
topViewSelectedTopAnchorConstraints.append(topView.topAnchor.constraint(equalTo: hiddenView.topAnchor))
NSLayoutConstraint.activate(topViewDefaultTopAnchorConstraints)
And here is how I am updating them:
func showTopView() {
NSLayoutConstraint.deactivate(topViewDefaultTopAnchorConstraints)
NSLayoutConstraint.activate(topViewSelectedTopAnchorConstraints)
UIView.animate(withDuration: 0.25) {
self.view.layoutIfNeeded()
}
}
Update:
Here is a gif of what happens when calling showTopView, calling it again fixes the bottom constraint:
It should just animate up like in the second image, not bringing the whole view up, as the bottomAnchor does not change, how can I fix this?
Update: I realised that I am rounding the corners of topView and bottomView, if I don't round the top corners then it works correctly, so it has something to do with this.
override func viewDidLayoutSubviews() {
topView.roundCorners(corners: [.topLeft, .topRight], radius: 100*ScreenDimensions.ASPECT_RATIO_RESPECT_OF_XMAX)
bottomView.roundCorners(corners: [.topLeft, .topRight], radius: 100*ScreenDimensions.ASPECT_RATIO_RESPECT_OF_XMAX)
topConstraint = topView.topAnchor.constraint(equalTo: hiddenView.topAnchor, constant: hiddenView.frame.height/2)
topConstraint.isActive = true
}
Seems the hiddenView's bottomAnchor is pulled up along with the topView's bottom here. Make sure the hiddenView's bottomAnchor is constrained properly. And better way to do this would be to provide a heightAnchor and increase the heightConstraint constant value to animate the view to increased height.
I think I know what your issue is. You might need to play with the constant rather than having two different constraint defined that you activate/deactivate.
First, define a constraint a the top of your ViewController like so:
var topConstraint: NSLayoutConstraint!
Then, initialize it as shown below:
hiddenView.addSubview(topView)
topView.translatesAutoresizingMaskIntoConstraints = false
topView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
topView.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
topView.bottomAnchor.constraint(equalTo: hiddenView.bottomAnchor).isActive = true
topConstraint = topView.topAnchor.constraint(equalTo: hiddenView.topAnchor, constant: hiddenView.frame.height/2)
topConstraint.isActive = true
Then inside showTopView, you only need to update the constant and that should create the animation you are looking for:
func showTopView() {
topConstraint.isActive = false
topConstraint.constant = 0
topConstraint.isActive = true
UIView.animate(withDuration: 0.25) {
self.view.layoutIfNeeded()
}
}
Similarly if you want to put the view back to normal, you'd implement it as follows:
func hideTopView() {
topConstraint.isActive = false
topConstraint.constant = hiddenView.frame.height/2
topConstraint.isActive = true
UIView.animate(withDuration: 0.25) {
self.view.layoutIfNeeded()
}
}
You may have better luck with this approach:
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let r = 100*ScreenDimensions.ASPECT_RATIO_RESPECT_OF_XMAX
topView.layer.cornerRadius = r
topView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
bottomView.layer.cornerRadius = r
bottomView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
}

How to programmatically disable constraint when animating

I am have a view with a fixed width anchored to the left. I want this view to animate (moving left to right) by anchoring it to the right (thus removing the left anchor). How do I do this?
I tried setting a priority but I am not sure of the syntax. I also tried to disable the constraint, but that did not work.
fileprivate func sparkle() {
let sparkleView = UIView()
sparkleView.backgroundColor = UIColor.yellow
sparkleView.alpha = 0.5
addSubview(sparkleView)
sparkleView.translatesAutoresizingMaskIntoConstraints = false
sparkleView.topAnchor.constraint(equalTo: topAnchor).isActive = true
sparkleView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
sparkleView.widthAnchor.constraint(equalToConstant: frame.width / 5).isActive = true
sparkleView.leftAnchor.constraint(equalTo: leftAnchor).priority = UILayoutPriorityDefaultLow // Doesn't work
sparkleView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
layoutIfNeeded()
sparkleView.leftAnchor.constraint(equalTo: leftAnchor).isActive = false // Doesn't work
sparkleView.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true
UIView.animate(withDuration: 2, animations: {
self.layoutIfNeeded()
}) { (_) in
sparkleView.removeFromSuperview()
}
}
Currently, the width constraint gets broken since we are assigning a left and right anchor.
Will attempt to recover by breaking constraint
<NSLayoutConstraint:0x6000020b8eb0 UIView:0x7fc846d551d0.width == 15.35 (active)>
-- EDIT --
I have tried creating an instance variable to hold the constraint.
let leftCons = sparkleView.leftAnchor.constraint(equalTo: leftAnchor)
leftCons.isActive = true
I then tried to modify the priority
leftCons.priority = UILayoutPriorityDefaultLow
However, this causes error
'Mutating a priority from required to not on an installed constraint (or vice-versa) is not supported. You passed priority 250 and the existing priority was 1000.'
Instead, I need to just set it as inactive (from the answers)
leftCons.active = false
-- Correct code if interested.. --
fileprivate func sparkle() {
let sparkleView = UIView()
sparkleView.backgroundColor = UIColor.yellow
sparkleView.alpha = 0.5
addSubview(sparkleView)
sparkleView.translatesAutoresizingMaskIntoConstraints = false
sparkleView.topAnchor.constraint(equalTo: topAnchor).isActive = true
sparkleView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
sparkleView.widthAnchor.constraint(equalToConstant: frame.width / 5).isActive = true
let leftCons = sparkleView.leftAnchor.constraint(equalTo: leftAnchor)
leftCons.isActive = true
layoutIfNeeded()
leftCons.isActive = false
sparkleView.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true
UIView.animate(withDuration: 0.5, animations: {
self.layoutIfNeeded()
}) { (_) in
sparkleView.removeFromSuperview()
}
}
You need to create a var
var leftCon:NSLayoutConstraint!
then alter it , you problem is every line create a new constraint
sparkleView.widthAnchor.constraint(equalToConstant: frame.width / 5).isActive = true
sparkleView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
sparkleView.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true
note: above 3 constraints create a conflict as the view can't be pinned to left and right at the same time with a width constraint that why you see the conflict break => NSLayoutConstraint:0x6000020b8eb0 UIView:0x7fc846d551d0.width == 15.35 (active)
regardless of the active assignment so those
sparkleView.leftAnchor.constraint(equalTo: leftAnchor).priority = UILayoutPriorityDefaultLow // Doesn't work
sparkleView.leftAnchor.constraint(equalTo: leftAnchor).isActive = false // Doesn't work
are on the fly and don't alter the created constraint , so to be
leftCon = sparkleView.leftAnchor.constraint(equalTo: leftAnchor)
leftCon.isActive = true
Regarding this crash
'Mutating a priority from required to not on an installed constraint (or vice-versa) is not supported
you need to set the priority before the activation
leftCon.priority = UILayoutPriorityDefaultLow
leftCon.isActive = true

How to add a view in a NSStackView with animation?

In interface builder, I have several views (A, B, C) in a NSStackView (vertical orientation).
During runtime, I change dynamically the NSStackView by showing or hiding (isHidden) some of these embedded views through a property observer (willSet). If the code below actually works (the views show or hide accordingly), I can't manage to do it with animation.
var isExpanded :Bool = false {
willSet {
NSAnimationContext.beginGrouping()
NSAnimationContext.current.duration = 2.0
if newValue {
viewA.isHidden = true
viewB.isHidden = false
viewC.isHidden = true
viewD.isHidden = true
print("Popover expanded")
} else {
viewA.isHidden = false
viewB.isHidden = false
viewC.isHidden = false
viewD.isHidden = false
print("Popover contracted")
}
NSAnimationContext.endGrouping()
}
As I understand, the isHidden state is not handled by the animation but I don't find other ways to do it.
Alternatively, I also tried to use addView and removeFromSuperview method (instead of hiding/showing). Same results...
My problem is that I mainly find iOS-related problems (UIView.animate...), and none about MacOS (NSView)...
Any ideasĀ ?
Many thanks for your help, Jo
I had the wrong approach: isHidden is not the right approach (can't animate a discrete value - it's hidden or not).
Instead, I added a constraint on the view's height
Connect the constraint in the viewController as an IBOutlet. With this code, the view smoothly squeeze in between 2 other views in a stackView. :-)
#IBOutlet weak var constraint: NSLayoutConstraint!
#IBAction func toggle(_ sender: NSButton) {
if constraint.constant == 0 {
NSAnimationContext.runAnimationGroup({context in
context.duration = 0.25
context.allowsImplicitAnimation = true
constraint.constant = 80
self.view.layoutSubtreeIfNeeded()
}, completionHandler: nil)
} else {
NSAnimationContext.runAnimationGroup({context in
context.duration = 0.25
context.allowsImplicitAnimation = true
constraint.constant = 0
self.view.layoutSubtreeIfNeeded()
}, completionHandler: nil)
}
}
Hope it helps.
Jo