I have a problem which I try to solve with 2 approaches. Both work to some extent, but then fail. The help with any of them will be highly appreciated. Also any other working approaches are welcome.
This is the task that needs to be accomplished:
I have an input component. For simplicity, let's say it contains only text field and send button. It is a view controller. The send button is another view controller that is embedded as a child view controller. The input component is then later embedded as a child view controller itself to some higher level container.
This can't be changed and we have to take this as granted.
The input controller should be clever enough to be able to display itself above the keyboard whenever its text field becomes first responder.
So, the first approach is adding the input's component contentView as the text field's inputAccessoryView.
func textFieldDidBeginEditing(_ textField: UITextField) {
// do some staff
// This needs to be done, because in above-keyboard position the input component has another layout compared to when it's inactive
contentView.layoutIfNeeded()
// This is the actual adding itself to be displayed above the keyboard
inputField.inputAccessoryView = contentView
}
As far as I understand more staff happens under the hood including adding and removing child view controllers.
Then, when the user taps return button or the send button, the following code is executed:
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
which triggers the
func textFieldDidEndEditing(_ textField: UITextField) {
// We add contentView back where it was before and reset constraints
view.addSubview(contentView)
contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
}
Hacky as hell, this code works. The only problem is that the view controller hierarchy seems to be corrupted (the input component is no longer displayed in view debugger, but the text field and button are on screen, so I was ok with that).
Then I've found the corner case. When the user dismisses keyboard by tapping anywhere on screen, or when he/she activates another text field, the textFieldShouldReturn is not called, but the textFieldDidEndEditing is called.
As a result, the contentView is removed from the inputAccessoryView, but it doesn't appear where it was before.
Debugging revealed that its superview ends up being nil, which leads me to the conclusion, that iOS at some point removes it even though I've added it as a view's subview.
If I add contentView.removeFromSuperview() before view.addSubview(contentView), then the app crushes with this error message
Thread 1: Exception: "child view
controller:UICompatibilityInputViewController: 0x7fa13b2a3c00 should
have parent view controller:(null) but actual parent
is:UIInputWindowController: 0x7fa136045000"
I don't understand how my code is guilty of this, so probably I do something that I am not supposed to from Apple's perspective.
This is why I tried the second approach.
This time I don't do anything with the inputAccessoryView. Instead I just modify the bottom constraint of the contentView to move up when keyboard is displayed:
func textFieldDidBeginEditing(_ textField: UITextField) {
contentViewBottomConstraint.constant = // whatever the keyboard's height is
UIView.animate(withDuration: viewModel.animationDurationForInputField,
delay: 0,
options: .curveEaseOut, animations: { [weak self] in
self?.view.layoutIfNeeded()
})
}
and to hide
func textFieldDidEndEditing(_ textField: UITextField) {
contentViewBottomConstraint.constant = 0
UIView.animate(withDuration: viewModel.animationDurationForInputField,
delay: 0,
options: .curveEaseOut, animations: { [weak self] in
self?.view.layoutIfNeeded()
})
}
This works like a charm and actually this should be the correct approach without messing around with inputAccessoryView and child-parent relationships of the view controllers.
But, the problems begin when this input component is embedded into the detail part of the master/detail controller. On iPad, the detail part takes around half of the screen. So is the width of our input component. And when the keyboard is displayed, the input component is pushed up, but it's width remains the same - half of the screen.
To make it full screen, I get the reference to the top window, deactivate old leading and trailing constraints and add new one - between top window and contentView.
guard let keyWindow = UIApplication.shared.keyWindow else { return }
contentViewBottomConstraint.constant = viewModel.bottomOffsetForInputField
NSLayoutConstraint.deactivate(originalContentViewConstraints)
contentViewToKeyboardLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: keyWindow.leadingAnchor)
contentViewToKeyboardLeadingConstraint?.isActive = true
contentViewToKeyboardTrailingConstraint = contentView.trailingAnchor.constraint(equalTo: keyWindow.trailingAnchor)
contentViewToKeyboardTrailingConstraint?.isActive = true
And it does become full screen, only half of it is not visible, because it is in the detail part of the master/detail, so it is overlapped by the master part.
And I am not able to bring it to front.
I tried adding it as subview to the window - crash because child/parent relationships are broken.
Moving subview to front - no effect (obviously, but I had to try).
Changing zPosition - no effect.
So, at the moment I've run out of ideas.
This is a very corner case, but I need to make it work because it (master/detail) is the actual layout in the application.
Thank you in advance for any help.
Related
Why restrain a subview from animation? Why not simply carry out its layout changes after the animation?
That's logical but not feasible considering my view hierarchy and this particular use-case.
My view hierarchy:
MyViewController: UIViewController -> MyCustomView: UIView -> MyCustomScrollView: UIScrollView, UIScrollViewDelegate, UICollectionViewDelegate, UICollectionViewDataSource
Why is it not possible?:
1) I do this in MyViewController after various constraint changes:
UIView.animate(withDuration: 0.3, animations: {
self.view.layoutIfNeeded()
})
2) Since MyCustomView is a subview which contains MyCustomScrollView (which in turn contains a UICollectionView as its subview), the layout update triggers CV's willDisplay delegate method under which I'm adding a bunch of labels to MyCustomView, to be precise.
Here's the function in MyCustomView that I'm calling:
func addLabel(forIndexPath indexPath: IndexPath) {
var label: UILabel!
label.frame = Util.labelFrame(forIndex: indexPath, fillWidth: false) // The frame for the label is generated here!
//Will assign text and font to the label which are unnecessary to this context
self.anotherSubView.addSubview(label) //Add the label to MyCustomView's subview
}
3) Since these changes get caught up within the animation block from point 1, I get some unnecessary, undesired animations happening. And so, MyCustomView's layout change is bound with this animation block, forcing me to look for a way to restrain this from happening
Things tried so far:
1) Tried the wrap the addSubView() from addLabel(forIndexPath:) inside a UIView.performWithoutAnimation {} block. - No luck
2) Tried the wrap the addSubView() from addLabel(forIndexPath:) inside another animation block with 0.0 seconds time as to see if this overrides the parent animation block - No luck
3) Explored UIView.setAnimationsEnabled(enabled:) but it seems like this won't cancel/pause the existing animators, and will completely disable all the animations if true (which is not what I want)
To sum this all up, my problem is:
I need to restrain the animations on MyCustomView but I need all my other desired layout changes to take place. Is this even possible? Would really appreciate a hint or a solution, TYIA!
Thanks to this answer, removing all the animations from anotherSubview's layer(inside the addLabel(forIndexPath:)) after adding the label:
self.anotherSubview.addSubview(label)
self.anotherSubview.layer.removeAllAnimations() //Doing this removes the animations queued to animate the label's frame into the view
does exactly what I want!
When I click on an a segment of my UISegmentedControl, I want one of two UIViews to be displayed. But how do I arrange it, that I only show the new view. Currently I am calling thisview.removeFromSuperview() on the old one, and then setup the new all from scratch. I also tried setting all the HeightConstants of the views subviews to zero and then set the heightConstants of the view itself to zero but I'd rather avoid that constraints-surgery..
What better approaches are there?
Agree with #rmaddy about using UIView's hidden property, a nice simple way to cause a view to not be drawn but still occupy its place in the view hierarchy and constraint system.
You can achieve a simple animation to make it a bit less jarring as follows:
UIView.animate(withDuration:0.4, animations: {
myView.alpha = 0
}) { (result: Bool) in
myView.isHidden = true
}
This will fade the alpha on the view "myView", then upon completion set it to hidden.
The same animation concept can be used also if you've views need to re-arrange themselves, animating layout changes will be a nice touch.
Based on #rmaddy and #CSmiths answer, I built the following function:
func changeView(newView: UIView, oldView: UIView) {
newView.isHidden = false
newView.alpha = 0
UIView.animate(withDuration:0.4, animations: {
oldView.alpha = 0
newView.alpha = 1
}) { (result: Bool) in
oldView.isHidden = true
}
}
I feel dumb now for all the hours I spent on that constraint-surgery. :|
Here is an image of a simple APP I am trying to make
The App has a pan gesture recogniser for the centre purple button, the button when moved if it intersects with any of the 4 orange buttons, the purple button animates and moves inside the bounds of button it intersects otherwise it animates back to the initial starting position i.e centre.
For the first time when I pan the purple button the frame of the button updates, but If I try for the second time the frame of button remains the same (the value when it's at the centre of the view).
I am guessing this is something related to Auto Layout that I am missing, because if I remove the constrains on the centre purple button the frame updates every time correctly if I pan.
Can anybody explain what I have to keep in mind when animating with constraints
Here is my code for handling the Pan gesture:
#objc func handleCenterRectPan(_ pannedView: UIPanGestureRecognizer) {
let fallBackAnimation = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 0.5) {
if self.button1.frame.intersects(self.centerRect.frame) {
self.centerRect.center = self.button1.center
} else if self.button2.frame.contains(self.centerRect.frame) {
self.centerRect.center = self.button2.center
} else if self.button3.frame.contains(self.centerRect.frame) {
self.centerRect.center = self.button3.center
} else if self.button4.frame.contains(self.centerRect.frame) {
self.centerRect.center = self.button4.center
} else {
// no intersection move to original position
self.centerRect.frame = OGValues.oGCenter
}
}
switch pannedView.state {
case .began:
self.centerRect.center = pannedView.location(in: self.view)
case .changed:
print("Changed")
self.centerRect.center = pannedView.location(in: self.view)
case .ended, .cancelled, .failed :
print("Ended")
fallBackAnimation.startAnimation();
//snapTheRectangle()
default:
break
}
}
First of all, you shouldn't be giving constraints if your aim is to move your views smoothly. And If you can't resist from constraints than you have to keep updating constraints and not the frames (centre),
In your code, I assume you have given your purple button centre constraints and then you are changing button's centre as user drag using the pan gesture. The problem is constraints you have given are still active and its try to set it back as soon as layout needs to update.
So what you can do is
Don't give constraints as its need to move freely when user drag
OR
Create IBoutlet for constraints and set .isActive to false when user dragging and make it true if it's not inside any of the other buttons and update UpdateLayoutIfNeeded, so it will come back to original position. (good approach)
#IBOutlet var horizontalConstraints: NSLayoutConstraint!
#IBOutlet var verticalConstraints: NSLayoutConstraint!
//make active false as the user is dragging the view
horizontalConstraints.isActive = false
verticalConstraints.isActive = false
self.view.layoutIfNeeded()
//make it true again if its not inside any of the other views
horizontalConstraints.isActive = true
verticalConstraints.isActive = true
self.view.layoutIfNeeded()
OR
Update constraints.constants for the horizontal and vertical constraints when user is moving the view (Not a good approach) and make it zero again if it's not inside of any other view
//make it true again if its not inside any of the other views
horizontalConstraints.constant = change in horizontal direction
verticalConstraints..constant = change in vertical direction
self.view.layoutIfNeeded()
Edit As you said constraints is nil after setting isActive = false because
what isActive actually do is add and remove those constraints as Apple doc says
Activating or deactivating the constraint calls addConstraint(:) and removeConstraint(:) on the view that is the closest common ancestor of the items managed by this constraint. Use this property instead of calling addConstraint(:) or removeConstraint(:) directly.
So making constraints strong reference does solve the issue but I think setting the strong reference to IBOutelet is not a good idea and adding and removing constraints programmatically is better. check out this question deallocating constraints
I'm using the following code to have a label slide onto the screen when a button is pressed, but it's having no effect.
override func viewDidLayoutSubviews() {
summaryLabel.alpha = 0
}
#IBAction func searchButton(sender: AnyObject) {
UIView.animateWithDuration(2) { () -> Void in
self.summaryLabel.center = CGPointMake(self.summaryLabel.center.x - 400, self.summaryLabel.center.y)
self.summaryLabel.alpha = 1
self.summaryLabel.center = CGPointMake(self.summaryLabel.center.x + 400, self.summaryLabel.center.y)
}
//button code continues...
I've tested what's going on by fixing the alpha at 1, but the label just stays where it is and does not move when the button is pressed. What am I missing?
A couple of things:
First of all, your two changes to the view's center cancel each other out. The animation applies the full set of changes that are inside the animation block all in one animation. If the end result is no change, then no change is applied. As Ramy says in his comment, you either need 2 animations, timed so the second one takes place after the first one has completed, or you nee to apply the first change before the animation begins. I would suggest starting with a single change, and a single animation, and get that working first.
Second problem: View controllers use auto layout by default. With auto layout, you can't animate the position of a view directly. It doesn't work reliably. Instead, you have to put a constraint on the view, connect it to an outlet, and the animate a change to the constraint's constant value by changing the constant and calling layoutIfNeeded() inside the animation block. The call to layoutIfNeeded() inside the animation block causes the view's position to be changed, and since it's inside the animation block, the change is applied with animation.
This question already has answers here:
How to create a custom keyboard
(5 answers)
Closed 6 years ago.
I have huge issues when trying to use a custom view as a input keyboard for a text field.
The setup is pretty simple (for testing) : I have a UIView with one button, half the size of the screen and a text field somewhere above it.
I set up a dummy view to be the input view of the text field like so let dummyView : UIView = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
Also I added the following setting on viewDidAppear testView.frame.origin.y = view.bounds.height. This is of course ignored as the view appears on screen (putting it on viewDidLoad leads to same behaviour). However, putting it in viewdidLayoutSubviews leads to a very strange behaviour, the view does not appear on screen, but trying to animate to, let's say a 380 origin.y does nothing.
The best part is yet to come: if I hide the view, then the slide up animation (code bellow)
UIView.animateWithDuration(0.6, animations: {
self.testView.frame = self.whereToBe!
}, completion: {
finished in
if finished {
}
})
works just fine. However, trying to slide it down again and resigningFirstResponder on the text field on button press (code bellow)
UIView.animateWithDuration(0.6, animations: {
//self.testView.frame = self.whereToBe!
self.testView.frame.origin.y = self.view.frame.height
}, completion: {
finished in
if finished {
self.view.endEditing(true)
//self.inputField.resignFirstResponder()
self.testView.hidden = true
}
})
shows a fascinating wrong behaviour: the view slides from top to its current position. However, on second press on the button, it works just fine, it slides down BUT it reappears on screen after the animation has completed.
Can you please help me?
Use the view as the inputView of the textfield by simply setting
self.textField.inputView = self.pickerView;
and avoid all these custom animations.