Height constraint animation 'jumping' - ios

I've been creating my own UIControl subclass for use in my tweak iDunnoU. I've finished the UIControl, with the exception of the expand/collapse animation. The issue with this animation is that it 'jumps' down/up when expanding/collapsing, instead of fanning out smoothly like my original mockup (see below).
I have uploaded the code to a GitHub repository, found here. The code for adding the control to the superview can be found here, the code for setting up the height constraint can be found here, and the code for animating the height constraint can be found here.

UIView.animate() can be a little tricky -- you need to call .layoutIfNeeded() on the correct view.
Replace your isExpanded / didSet in iDUMenuButton class with this:
var isExpanded = false {
didSet {
if isExpanded != oldValue {
if isExpanded {
becomeFirstResponder()
let haptics = UIImpactFeedbackGenerator(style: .rigid)
haptics.impactOccurred()
}
guard let sv = self.superview else {
// shouldn't happen, but let's be thorough
fatalError("Self must have a superview!!!")
}
// not needed
//self.layoutIfNeeded()
UIView.animate(withDuration: 0.3) {
self.heightConstraint.isActive = !self.isExpanded
// call .layoutIfNeeded() on self's superview
//self.layoutIfNeeded()
sv.layoutIfNeeded()
self.layer.shadowOpacity = self.isExpanded ? 1 : 0
self.buttons.forEach { $0.setBadgeHidden(hidden: !self.isExpanded, animated: true) }
}
delegate?.menuButton(self, isExpandedDidUpdate: isExpanded)
}
}
}

Related

Unwanted UIView frame reset after inserting text in UITextField

I'm stuck with some funny problem and ran out of ideas how to solve it.
In one of my controllers I use a simple scheme of adjusting a view frame according to the keyboard appearance.
In UITextFieldDelegate method I initialise the controller's property firstResponder:
func textFieldDidBeginEditing(_ textField: UITextField) {
self.firstResponder = textField
}
Then I use UIKeyboard notifications selectors to change the frame of contentView:
override func keyboardWillShow(_ notifications: Notification) {
super.keyboardWillShow(notifications)
let info = notifications.userInfo
let keyboardFrame:CGRect = (info![UIKeyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
let duration:Double = (info![UIKeyboardAnimationDurationUserInfoKey] as! NSNumber).doubleValue
var bottomY:CGFloat!
if self.firstResponder == self.emailTextField{
bottomY = self.emailBottomLine.frame.origin.y + 80 + self.headerView.frame.height
}
else {
return
}
if bottomY >= keyboardFrame.origin.y {
let offset = bottomY - keyboardFrame.origin.y
UIView.animate(withDuration: duration, animations: {
self.contentView.frame.origin.y = -offset
})
}else{
UIView.animate(withDuration: duration, animations: {
self.contentView.frame.origin.y = self.contentViewOriginY
})
}
}
override func keyboardWillHide(_ notifications: Notification) {
super.keyboardWillHide(notifications)
let info = notifications.userInfo
let duration:Double = (info![UIKeyboardAnimationDurationUserInfoKey] as! NSNumber).doubleValue
UIView.animate(withDuration: duration, animations: {
self.contentView.frame.origin.y = self.contentViewOriginY
})
}
And everything works fine until I start typing inside the emailTextField. Each tap on the keyboard causes the contentView reset to its original position without animation.
The question is what really causes this behavior? I'm totally confused and have checked whatever thing is possibly affects this. Please, help!!!
Make sure either:
A. Your contentView does not have any layout constraints attached to it, or else when you set its frame, its frame will be reset on the next layout pass back to what the constraints say the frame should be.
or:
B. Use a constraint to position your contentView's vertical offset relative to the keyboard instead of adjusting its frame.

Having trouble with LargeTitle and a segmented control with a table view

Sample project can be found at https://github.com/SRowley90/LargeTitleIssueTestiOS
I am trying to position a segmented control below the Large title in an iOS app. I have a UIToolbar which contains the segmented control inside.
When scrolling up the title and toolbar behave as expected.
When scrolling down the navigation bar is correct, but it doesn't push the UITabBar or the UITableView down, meaning the title goes above the segmented control as can be seen in the images below.
I'm pretty sure it's something to do with the constraints I have set, but I can't figure out what.
The TabBar is fixed to the top, left and right.
The TableView is fixed to the bottom, left and right.
The tableView is fixed vertically to the TabBar
I have the position UITabBarDelegate method set:
func position(for bar: UIBarPositioning) -> UIBarPosition {
return .topAttached
}
Take the delegation of the tableView somewhere:
tableView.delegate = self
Override the scrollViewDidScroll and update toolbar position appearance (since the real position should not change according to have that nice bounce effect.
extension ViewController: UIScrollViewDelegate {
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
var verticalOffset = scrollView.contentOffset.y + defaultNavigationBarHeight
if scrollView.refreshControl?.isRefreshing ?? false {
verticalOffset += 60 // After is refreshing changes its value the toolbar goes 60 points down
print(toolbar.frame.origin.y)
}
if verticalOffset >= 0 {
toolbar.transform = .identity
} else {
toolbar.transform = CGAffineTransform(translationX: 0, y: -verticalOffset)
}
}
}
You can use the following check before applying transformation to make it more reliable and natural to default iOS style:
if #available(iOS 11.0, *) {
guard let navigationController = navigationController else { return }
guard navigationController.navigationBar.prefersLargeTitles else { return }
guard navigationController.navigationItem.largeTitleDisplayMode != .never else { return }
}
Using UIScrollViewDelegate didn't work well with CollectionView and toolbar for me. So, I did:
final class CollectionViewController: UICollectionViewController {
private var observesBag: [NSKeyValueObservation] = []
private let toolbar = UIToolbar()
override func viewDidLoad() {
super.viewDidLoad()
let statusBarHeight = UIApplication.shared.statusBarFrame.height
let navigationBarHeight = navigationController?.navigationBar.frame.height ?? 0
let defaultNavigationBarHeight = statusBarHeight + navigationBarHeight
let observation = navigationController!
.navigationBar
.observe(\.center, options: NSKeyValueObservingOptions.new) { [weak self] navBar, _ in
guard let self = self else { return }
let newNavigatonBarHeight = navBar.frame.height + statusBarHeight
let yTranslantion = newNavigatonBarHeight - defaultNavigationBarHeight
if yTranslantion > 0 {
self.toolbar.transform = CGAffineTransform(
translationX: 0,
y: yTranslantion
)
} else {
self.toolbar.transform = .identity
}
}
observesBag.append(observation)
}
}
Observe the "center" of the navigationBar for changes and then translate the toolbar in the y-axis.
Even though it worked fine when I tried to use this solution with UIRefreshControl and Large Titles it didn't work well.
I set up the refresh control like:
private func setupRefreshControl() {
let refreshControl = UIRefreshControl()
self.webView.scrollView.refreshControl = refreshControl
}
the height of the UINavigationBar is changed after the complete refresh triggers.

UIViewPropertyAnimator issue with Autolayout

Here is the code of what I tried to repeat according to Apple WWDC but with autolayout:
extension AugmentedReallityViewController {
#objc func handlePan(recognizer: UIPanGestureRecognizer) {
// // hide languages and units anyway
// moveUnitView(show: false)
// moveLanguageView(show: false)
//
// let isNowExpanded = settingsPanelState == SettingsPanelState.expanded
// let newState = isNowExpanded ? SettingsPanelState.collapsed : SettingsPanelState.expanded
//
// switch recognizer.state {
// case .began:
// startInteractiveTransition(state: newState, duration: 1)
// isLastPanelUpdateToReachTheNewState = true // just in case, but we should change this property later
// case .changed:
// let translation = recognizer.translation(in: viewSettings)
// let fractionComplete = translation.y / viewSettings.frame.size.height
//
// // we will use this property when interaction ends
// if fractionComplete != 0 { // if it's == 0 , we need to use prev data
// isLastPanelUpdateToReachTheNewState = (newState == SettingsPanelState.expanded && fractionComplete < 0) || (newState == SettingsPanelState.collapsed && fractionComplete > 0)
// }
//
// updateInteractiveTransition(fractionComplete: fractionComplete)
// case .ended:
// continueInteractiveTransition(cancel: !isLastPanelUpdateToReachTheNewState)
// default:
// break
// }
}
#objc func handleSettingsTap() {
// hide languages and units anyway
moveUnitView(show: false)
moveLanguageView(show: false)
let isNowExpanded = settingsPanelState == SettingsPanelState.expanded
let newState = isNowExpanded ? SettingsPanelState.collapsed : SettingsPanelState.expanded
animateOrReverseRunningTransition(state: newState, duration: 10)
}
// perform all animations with animators if not already running
private func animateTransitionIfNeeded(state: SettingsPanelState, duration: TimeInterval) {
if runningAnimators.isEmpty {
// // define constraint for frame animation
// // update constraints
// switch state {
// case .expanded:
// constraint_settingsView_bottom.constant = 0
// case .collapsed:
// constraint_settingsView_bottom.constant = -constraint_height_settingViewWhitePart.constant
// }
// animate that
let frameAnimator = UIViewPropertyAnimator(duration: duration, curve: .linear, animations: { [weak self] in
if let strongSelf = self {
// define constraint for frame animation
// update constraints
switch state {
case .expanded:
strongSelf.constraint_settingsView_bottom.constant = 0
case .collapsed:
strongSelf.constraint_settingsView_bottom.constant = -(strongSelf.constraint_height_settingViewWhitePart.constant)
}
}
self?.view.layoutIfNeeded()
})
frameAnimator.startAnimation()
runningAnimators.append(frameAnimator)
frameAnimator.addCompletion({ [weak self] (position) in
if position == UIViewAnimatingPosition.end { // need to remove this animator from array
if let index = self?.runningAnimators.index(of: frameAnimator) {
print("removed animator because of completion")
self?.runningAnimators.remove(at: index)
// we can change state to a new one
self?.settingsPanelState = state
}
else {
print("animator completion with state = \(position)")
}
}
})
}
}
// starts transition if neccessary or reverses it on tap
private func animateOrReverseRunningTransition(state: SettingsPanelState, duration: TimeInterval) {
if runningAnimators.isEmpty { // start transition from start to end
animateTransitionIfNeeded(state: state, duration: duration)
}
else { // reverse all animators
for animator in runningAnimators {
animator.stopAnimation(true)
animator.isReversed = !animator.isReversed
// test
print("tried to reverse")
}
}
}
// called only on pan .begin
// starts transition if neccessary and pauses (on pan .begin)
private func startInteractiveTransition(state: SettingsPanelState, duration: TimeInterval) {
animateTransitionIfNeeded(state: state, duration: duration)
for animator in runningAnimators {
animator.pauseAnimation()
// save progress of any item
progressWhenInterrupted = animator.fractionComplete
}
}
// scrubs transition on pan .changed
private func updateInteractiveTransition(fractionComplete: CGFloat) {
for animator in runningAnimators {
animator.fractionComplete = fractionComplete + progressWhenInterrupted
}
}
// continue or reverse transition on pan .ended
private func continueInteractiveTransition(cancel: Bool) {
for animator in runningAnimators {
// need to continue or reverse
if !cancel {
let timing = UICubicTimingParameters(animationCurve: .easeOut)
animator.continueAnimation(withTimingParameters: timing, durationFactor: 0)
}
else {
animator.isReversed = true
}
}
}
private func addPanGustureRecognizerToSettings() {
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(AugmentedReallityViewController.handlePan(recognizer:)))
// panGestureRecognizer.cancelsTouchesInView = false
viewSettings.addGestureRecognizer(panGestureRecognizer)
}
private func addTapGestureRecognizerToSettings() {
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(AugmentedReallityViewController.handleSettingsTap))
tapGestureRecognizer.cancelsTouchesInView = false
viewSettingsTopTriangle.addGestureRecognizer(tapGestureRecognizer)
}
}
Right now I'm just testing tap gestures. And there are 2 main issues:
1) Tap recognizer doesn't work properly during animation. But in apple WWDC they changed frames (not constraints like in my case) and tap recognizers worked perfectly
2) If I change reverse property it changes constraints really very bad. I have extra strips and so on
3) I tried both ways to put changing constraint before animation block and inside. It doesn't really matter, works the same
Any help how to do that with autolayout? Or at least how to do it with frames but my view controller is based on autolayout, so anyway I will have constraints to this bottom view.
When you are using autolayout for animations, you do it as follows:
Make sure autolayout is done:
self.view.layoutIfNeeded()
Then you change the constraints BEFORE the animation block. So for example:
someConstraint.constant = 0
Then after changing the constraint, you tell the autolayout that constraints have been changed:
self.view.setNeedsLayout()
And then you add an animation block with simply calling layoutIfNeeded():
UIView.animate(withDuration: 1, animations: {
self.view.layoutIfNeeded()
})
The same applies when you use UIViewPropertyAnimator - change the constraints in the animation block. E.g.:
self.view.layoutIfNeeded()
someConstraint.constant = 0
self.view.setNeedsLayout()
let animator = UIViewPropertyAnimator(duration: 1, curve: .easeInOut) {
self.view.layoutIfNeeded()
}
animator.startAnimation()
This happens because layoutIfNeeded() does the actual layout - it calculates the frames of the affected views. So if you are setting frames directly, you set them in the animation block. However, Autolayout sets the frames for you - therefore what you need is to tell the autolayout to set them in the animation block (as you would do, if you would set them directly). The layoutIfNeeded() call does exactly that - it tells the autolayout engine to calculate and set the new frames.
About reversal:
While I don't have enough experience to be 100% sure, I would expect that simply setting the animator to reverse would not suffice. Since you apply the constraints before starting the animation, and then you just tell the autolayout to update frames according to the constraints - I would assume that when you reverse the animator, you would also need to reverse also the constraints that are driving the animation.
Animator just animates views into new frames. However, reversed or not, the new constraints still hold regardless of whether you reversed the animator or not. Therefore after the animator finishes, if later autolayout again lays out views, I would expect the views to go into places set by currently active constraints. Simply said: The animator animates frame changes, but not constraints themselves. That means reversing animator reverses frames, but it does not reverse constraints - as soon as autolayout does another layout cycle, they will be again applied.
The important thing to set self.view.layoutIfNeeded() animation to happen
private func animateCard(with topOffset: CGFloat) {
let animator = UIViewPropertyAnimator(duration: 1, curve: .easeOut)
animator.addAnimations {
self.topCardConstraint?.constant = topOffset
self.view.layoutIfNeeded()
}
animator.startAnimation()
}

StackView isHidden attribute not updating as expected

I'm trying to update a UIStackView so that a field displays, should the value of a UITextField equal "Other". Here is my code:
#IBOutlet var stackView: UIStackView!
func updateView() {
print("UPDATING")
UIView.animate(withDuration: 0.25, animations: { () -> Void in
if(self.myTextField.text! == "Other") {
print("SHOWING")
self.stackView.arrangedSubviews[3].isHidden = false
} else {
print("HIDING")
self.stackView.arrangedSubviews[3].isHidden = true
}
print("Is hidden: \(self.stackView.arrangedSubviews[3].isHidden )")
})
An example output looks like this:
> UPDATING
> HIDING
> Is hidden: true
> UPDATING
> SHOWING
> Is hidden: true
As you can see, the isHidden attribute is reported as true, regardless of what the code above has set it to. I can't really figure out why that might be, but perhaps someone on here can? Is there something obvious to check? Is there any reason why isHidden can't be updated? (note there are no errors appearing in the output).
It's known UIStackView bug (http://www.openradar.me/25087688). There is a thread on SO about it: (Swift: Disappearing views from a stackView). Long story short:
The bug is that hiding and showing views in a stack view is
cumulative. Weird Apple bug. If you hide a view in a stack view twice,
you need to show it twice to get it back.
To fix this issue you can use following extension:
extension UIView {
var isHiddenInStackView: Bool {
get {
return isHidden
}
set {
if isHidden != newValue {
isHidden = newValue
}
}
}
}
Updates on the user interface always have to be done on the main thread (THE LAW).
So wrap you UI updates on the main thead:
#IBOutlet var stackView: UIStackView!
func updateView() {
print("UPDATING")
UIView.animate(withDuration: 0.25, animations: { () -> Void in
DispatchQueue.main.async { // UI updates on the main thread
if(self.myTextField.text! == "Other") {
print("SHOWING")
self.stackView.arrangedSubviews[3].isHidden = false
} else {
print("HIDING")
self.stackView.arrangedSubviews[3].isHidden = true
}
print("Is hidden: \(self.stackView.arrangedSubviews[3].isHidden )")
}
})
try to manipulate alpha along with isHidden property:
self.stackView.arrangedSubviews[3].isHidden = true
self.stackView.arrangedSubviews[3].alpha = 0
self.stackView.arrangedSubviews[3].isHidden = false
self.stackView.arrangedSubviews[3].alpha = 1

UIButton exceeding view controller without clipping to its bounds

In my app I'm using KYDrawerController library from here: https://github.com/ykyouhei/KYDrawerController
It works as expected, but I want to add an UIButton on the menu view controller (which is on top when opened), however it's clipped by view bounds. Best way I can explain this is by showing you a screenshot:
And here's how it should look like:
Button now has negative right constraint margin so it's position is correct, but how can I disable clipping?
In the menu view controller, which can you see on the foreground, I've added this code:
self.navigationController?.navigationBar.clipsToBounds = false
self.navigationController?.view.clipsToBounds = false
self.view.clipsToBounds = false
let elDrawer = self.navigationController?.parent as! KYDrawerController
elDrawer.view.clipsToBounds = false
elDrawer.displayingViewController?.view.clipsToBounds = false
elDrawer.drawerViewController?.view.clipsToBounds = false
elDrawer.displayingViewController?.view.clipsToBounds = false
elDrawer.mainViewController.view.clipsToBounds = false
elDrawer.inputViewController?.view.clipsToBounds = false
elDrawer.splitViewController?.view.clipsToBounds = false
As you can see I've tried all possible ways to disable clipping, yet it's still clipped. How can I achieve this?
edit:
Added hierachy view:
I've also tried to run following test:
var view = arrowButton as UIView?
repeat {
view = view?.superview
if let sview = view {
if(sview.clipsToBounds){
print("View \(view) clips to bounds")
break
}
else{
print("View \(view) does not clip to bounds")
}
}
} while (view != nil)
And it prints:
View Optional(>) does not clip to
bounds
So looks like nothing is clipping yet it's clipped.
edit2:
debug view hierarchy:
Yay, I've found the solution:
self.navigationController?.view.subviews[0].clipsToBounds = false
old code is not needed.
UINavigationTransitionView (it's Apple private class) was the one responsible with clipToBounds turned on.
We can't really tell what's going on because you don't show us the view hierarchy.
I suggest you write test code that starts at the button, walking up the superview hierarchy looking for a superview who's' clipsToBounds is true. Something like this:
var view = button as UIView?
repeat {
view = view.superview
if view?.superview.clipsToBounds ?? false == true {
print("View \(view) clips to bounds")
break
} while view != nil
Then you'll know which view is clipping, and you can fix it witout the "shotgun" approach you're using.
EDIT:
I'm not sure why you are getting such strange debug messages. I suggest adding unique tag numbers to all the button's superviews, and then using code like this:
override func viewDidAppear(_ animated: Bool) {
var view = button as UIView?
repeat {
view = view?.superview
if let view = view {
let tag = view.tag
let description = (tag == 0) ? view.debugDescription : "view w tag \(tag)"
if(view.clipsToBounds){
print("View \(description) clips to bounds")
break
}
else{
print("View \(description) does not clip to bounds")
}
}
} while (view != nil)
}
Post ALL the output from that debug code so we can see the entire view hierarchy you're dealing with.

Resources