When implementing custom view controller presentations, where to apply presented view's constraints? - ios

When presenting a view controller using a custom animation, none of Apple's documentation or example code mentions or includes constraints, beyond the following:
// Always add the "to" view to the container.
// And it doesn't hurt to set its start frame.
[containerView addSubview:toView];
toView.frame = toViewStartFrame;
The problem is that the double-height status bar is not recognized by custom-presented view controllers (view controllers that use non-custom presentations don't have this problem). The presented view controller is owned by the transition's container view, which is a temporary view provided by UIKit that we have next to no dominion over. If we anchor the presented view to that transient container, it only works on certain OS versions; not to mention, Apple has never suggested doing this.
UPDATE 1: There is no way to consistently handle a double-height status bar with custom modal presentations. I think Apple botched it here and I suspect they will eventually phase it out.
UPDATE 2: The double-height status bar has been phased out and no longer exists on non-edge-to-edge devices.

My answer is: You should not use constraints in case of custom modal presentations
Therefore I know your pain, so I will try to help you to save time and effort by providing some hints which I suddenly revealed.
Example case:
Card UI animation like follows:
Terms for further use:
Parent - UIViewController with "Detail" bar button item
Child - UIViewController with "Another"
Troubles you mentioned began, when my animation involved size change along with the movement. It causes different kinds of effects including:
Parent's under-status-bar area appeared and disappeared
Parent's subviews were animated poorly - jumps, duplication and other glitches.
After few days of debugging and searching I came up with the following solution (sorry for some magic numbers ;)):
UIView.animate(withDuration: transitionDuration(using: transitionContext),
delay: 0,
usingSpringWithDamping: 1,
initialSpringVelocity: 0.4,
options: .curveEaseIn, animations: {
toVC.view.transform = CGAffineTransform(translationX: 0, y: self.finalFrame.minY)
toVC.view.frame = self.finalFrame
toVC.view.layer.cornerRadius = self.cornerRadius
fromVC.view.layer.cornerRadius = self.cornerRadius
var transform = CATransform3DIdentity
transform = CATransform3DScale(transform, scale, scale, 1.0)
transform = CATransform3DTranslate(transform, 0, wdiff, 0)
fromVC.view.layer.transform = transform
fromVC.view.alpha = 0.6
}) { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
Main point here is, that You have to use CGAffineTransform3D to avoid animation problems and problems with subviews animation (2D Transforms are not working for unknown reasons).
This approach fixes, I hope, all your problems without using constraints.
Feel free to ask questions.
UPD: According to In-Call status bar
After hours of all possible experiments and examining similar projects like this and this and stackoverflow questions like this, this (it's actually fun, OPs answer is there) and similar I am totally confused. Seems like my solution handles Double status bar on UIKit level (it adjusts properly), but the same movement is ignoring previous transformations. The reason is unknown.
Code samples:
You can see the working solution here on Github
P.S. I'm not sure if it's ok to post a GitHub link in the answer. I'd appreciate for an advice how to post 100-300 lines code In the answer.

I've been struggling with double-height statusBar in my current project and I was able to solve almost every issue (the last remaining one is a very strange transformation issue when the presentingViewController is embedded inside a UITabBarController).
When the height of the status bar changes, a notification is posted.
Your UIPresentationController subclass should subscribe to that particular notification and adjust the frame of the containerView and its subviews:
UIApplication.willChangeStatusBarFrameNotification
Here is an example of code I'm using:
final class MyCustomPresentationController: UIPresentationController {
// MARK: - StatusBar
private func subscribeToStatusBarNotifications() {
let notificationName = UIApplication.willChangeStatusBarFrameNotification
NotificationCenter.default.addObserver(self, selector: #selector(statusBarWillChangeFrame(notification:)), name: notificationName, object: nil)
}
#objc private func statusBarWillChangeFrame(notification: Notification?) {
if let newFrame = notification?.userInfo?[UIApplication.statusBarFrameUserInfoKey] as? CGRect {
statusBarWillChangeFrame(to: newFrame)
} else {
statusBarWillChangeFrame(to: .zero)
}
}
func statusBarWillChangeFrame(to newFrame: CGRect) {
layoutContainerView(animated: true)
}
// MARK: - Object Lifecycle
deinit {
// Unsubscribe from all notifications
NotificationCenter.default.removeObserver(self)
}
// MARK: - Layout
/// Called when the status-bar is about to change its frame.
/// Relayout the containerView and its subviews
private func layoutContainerView(animated: Bool) {
guard let containerView = self.containerView else { return }
// Retrieve informations about status-bar
let statusBarHeight = UIApplication.shared.statusBarFrame.height
let normalStatusBarHeight = Constants.Number.statusBarNormalHeight // 20
let isStatusBarNormal = statusBarHeight ==~ normalStatusBarHeight
if animated {
containerView.frame = …
updatePresentedViewFrame(animated: true)
} else {
// Update containerView frame
containerView.frame = …
updatePresentedViewFrame(animated: false)
}
}
func updatePresentedViewFrame(animated: Bool) {
self.presentedView?.frame = …
}
}

Related

Hide one UIView and show another

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. :|

Why is this code to add a subview executing in this sequence?

I am making a small view show up after a long press (iconsContainerView) and am not understanding why the code in handleLongPress(gesture:) is executing in the manner that it is. It's my understanding that it should go top to bottom and each line should run immediately. Meaning as soon as view.addSubview(iconsContainerView) runs, the view should show up in the top left of the screen, as its opacity has not yet been set to 0.
So, the code as written (once the gesture has begun) seems like the view would be shown on the screen in the top left, then move when it's transformed, then disappear (when the opacity is set to 0), then re-appear in the animation when the opacity is set to 1. But what happens is the view doesn't even show up until the code hits the animate block.
So, everything works how I want it to – I do want the subview to fade in after the long press. But I'm just trying to understand what's behind this and why each line of code isn't being immediately executed (or at least showing up on the screen that way). It is running on the main thread, and I've dropped break points in and verified the lines are running in sequence.
class ViewController: UIViewController {
let iconsContainerView: UIView = {
let containerView = UIView()
containerView.backgroundColor = .red
containerView.frame = CGRect(x: 0, y: 0, width: 200, height: 100)
return containerView
}()
override func viewDidLoad() {
super.viewDidLoad()
setUpLongPressGesture()
}
fileprivate func setUpLongPressGesture() {
view.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)))
}
#objc func handleLongPress(gesture: UILongPressGestureRecognizer) {
print("Long gesture", Date())
if gesture.state == .began {
view.addSubview(iconsContainerView)
let pressedLocation = gesture.location(in: view)
let centeredX = (view.frame.width - iconsContainerView.frame.width) / 2
iconsContainerView.transform = CGAffineTransform(translationX: centeredX, y: pressedLocation.y - iconsContainerView.frame.height)
iconsContainerView.alpha = 0
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: .curveEaseOut, animations: {
self.iconsContainerView.alpha = 1
})
} else if gesture.state == .ended {
iconsContainerView.removeFromSuperview()
}
}
}
I think you're expecting that your code functions like this
you add a subview
system draws the view on the screen
you update the views transform
system redraws the view on the screen
you updates the views alpha
system redraws the view on the screen
Since your code is running on the main thread and the systems drawing code also runs on the main thread, theres no way they both could be running at the same time or flip flopping between the two.
What actually happens is that behind the scenes your app has a loop (a RunLoop) that is always running. The simplest way to think about it is that it
handles input
draws views to the screen
repeat
Your code would fall into the handle input part. So you whole method has to finish running before the loop can move onto the next step which is drawing the views to the screen. This is also why it is important not to do a lot of work on the main thread, if your method takes a second to run that would mean that the app could not draw to the screen or handle additional input for 1 whole second which would make the app seem frozen.
Side Notes
In reality the main run loop can have a lot more stuff going on in it. It also has a lot of optimizations to make sure its only running when it needs to be to avoid constantly running the cpu or redrawing when nothing has changed which would murder your battery life. This should be enough of an understanding for the majority of iOS development, unless you starting directly interacting with the main run loop or create other run loops but that is rarely needed.

Custom UIView starts off covering only a portion of the screen

I have a strange problem. I have a custom UIView that is supposed to fill the screen. Here is a picture of the GUI along with the constraints:
Now the main problem is, on the iPad Pro 12.9" simulator, at first the custom view only fills a portion of the screen- like it was following the Air 2 size constraints. However, if I go away from the screen and come back to it such that the screen isn't recreated but just redisplayed, the gui looks almost perfect. On the other hand, the gui looks almost perfect on the iPad Mini device that I have, without having to go and come back. It isn't quite there because the image in the middle section gets clipped slightly at the top and bottom, but I haven't tried hard to figure out why that is happening. I have spent a fair amount of time trying to debug the problem I am asking about. If you need more information to help me solve this problem, I'm happy to provide it- just specify what you need. On the view controllers that actually hold this custom view, I use autoresizing masks to have it fill the screen, which apparently isn't working, but constraints have been tried and they didn't help either.
Any ideas on how to fix this?
UPDATE: I changed the constraints to something I liked better, as I had used "Reset to Suggested Constraints" and that created some weird constraints. Problem still exists, however.
Here is some of the code involving the view:
class SessionDisplayViewController: SessionViewDisplayViewControllerBase
{
//some code omitted for succinctness
#IBOutlet weak var mySessionView: SessionDisplayView!
override func getSessionView() -> SessionDisplayView
{
return mySessionView
}
...
}
class SessionViewDisplayViewControllerBase: UIViewController, SessionDisplayViewDelegate{
...
override func viewDidLoad() {
super.viewDidLoad()
...
if !ShareData.sharedInstance.sessionDataObjectContainer.keys.contains(curSessName) || ShareData.sharedInstance.sessionDataObjectContainer[curSessName] == nil
{
setupMySession(isLive: false, isFinalized: false)
}
else if (ShareData.sharedInstance.sessionDataObjectContainer[curSessName]?.isFinalized)!
{
setupMySession(isLive: false, isFinalized: true)
}
else
{
setupMySession(isLive: true, isFinalized: false)
var fromTempChoose = ShareData.sharedInstance.startingSessionFromTempChoose && !(ShareData.sharedInstance.globalsVar?.hasStartedSession)!
if fromTempChoose || (ShareData.sharedInstance.resumingSessionFromSessDet && !(ShareData.sharedInstance.globalsVar?.hasResumedSession)!)
{
let mySessionView = getSessionView()
mySessionView.curScene.pauseSession(isStartingNow: true)
blurEffect = UIBlurEffect(style: UIBlurEffectStyle.light)
//}
blurEffectView = UIVisualEffectView(effect: blurEffect)
//always fill the view
blurEffectView?.frame = self.view.bounds
blurEffectView?.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.view.addSubview(blurEffectView!)
}
...
}
var mySessObj = getSessionView() //these three lines of code were added to try to fix the problem. They weren't in the original code
mySessObj.frame = self.view.bounds
setNeedsDisplay()
}
...
func setupMySession(isLive: Bool, isFinalized: Bool)
{
let mySessionView = getSessionView()
//mySessionView.translatesAutoresizingMaskIntoConstraints = false
mySessionView.delegate = self
sessionNameIndex = self.getSessionNumber() - 1
let myName = ShareData.sharedInstance.currentAccount.name
var curSessName = generateCurrentAccountName(name: myName!, value: self.getSessionNumber())
//var names = generateAllPossibleSessionNames(name: myName!)
let curSession = ShareData.sharedInstance.sessionDataObjectContainer[curSessName]
mySessionView.onView(index: getSessionNumber(), sessionName: curSessName, isLive: isLive, isFinalized: isFinalized)
if isLive
{
let val = curSession?.currentValue()
mySessionView.curScene.setStartPosition(newValue: val!)
}
}
It took me awhile to figure this one out. The software was adding an auto resizing mask constraint to the view, which was causing it to look funny. In order to fix this problem, in the initialization of the custom UIView, I had to add the following lines of code:
self.view.autoresizingMask = [.flexibleHeight, .flexibleWidth]
self.view.frame = self.bounds
I guess the software treats the class that is the custom UIView and the view inside it that holds everything as two separate views. I wish the tutorials I found online about creating a custom UIView had included that we need to set the auto resizing mask and the view's frame, but oh well. Lesson learned.

Custom animation for UISearchController?

I'm presenting a UISearchController from my controller embedded in a navigation controller. The default animation occurs, where the search box drops down from the top on the navigation bar.
This isn't a good UX in my case because I present the search when a user taps into a UITextField in the middle of the screen. What I'd like to do is have the UITextField float to the top and morph into the search box, but I can't figure how to do this.
This is what I have:
class PlacesSearchController: UISearchController, UISearchBarDelegate {
convenience init(delegate: PlacesAutocompleteViewControllerDelegate) {
let tableViewController = PlacesAutocompleteContainer(
delegate: delegate
)
self.init(searchResultsController: tableViewController)
self.searchResultsUpdater = tableViewController
self.hidesNavigationBarDuringPresentation = false
self.definesPresentationContext = true
self.searchBar.placeholder = searchBarPlaceholder
}
}
private extension ShowAddressViewController {
#objc func streetAddressTextFieldEditingDidBegin() {
present(placesSearchController, animated: true, completion: nil)
}
}
Instead of the search dropping down from the top, I'm hoping to get the text field fly up to the nav bar. What I’m after is the same effect that’s on the iOS 11 File app:
It has a text field in the middle of the screen then animated up to the navigation bar when you tap on it. In my case though, the text field is way lower in the screen and not originally part of the navigation bar.
UISearchController
UISearchController is a component that highly difficult to customize. From my experience I can say, that it is better to use it as is without any drastic or significant customization. Otherwise, customization could result in messy code, global state variables, runtime tricks with UIView hierarchy etc.
If specific behavior still needed, it is better to implement search controller from scratch or use third party one.
Default implementation
Looks like UISearchController was designed to be used in couple with UITableView and UISearchBar installed in the table header view. Even apple official code samples and documentation provides such example of usage (see UISearchController). Attempt to install UISearchBar somewhere else often results in numerous ugly side effects with search bar frame, position, twitching animations, orientation changes etc.
Starting with iOS 11, UINavigationItem got searchController property. It allows to integrate search controller into your navigation interface, so search will look exactly like iOS Files or iOS AppStore app. UISearchController's behavior still coupled with another component, but I believe it is better than coupling with UITableView all the time.
Possible solutions
From my perspective there are several possible solutions for your problem. I will provide them in order of increasing effort, which is needed for implementation:
If you still want to use UISearchController, consider to use it as is without significant customizations. Apple provides sample code, that demonstrates how to use UISearchController (see Table Search with UISearchController).
There are several third party libraries which may be more flexible with lots of additional features. For example: YNSearch, PYSearch. Please, have a look.
Along with UISearchController presentation, you could try to move UITextField up with changing alpha from 1 to 0. This will create a feeling that UITextField is smoothly transforming to UISearchBar. This approach described in article that was provided by Chris Slowik (see comment to your original post). The only thing I would improve is animations using transition coordinators (see example here), it will make animations smoother with synchronized timing. Final implementation also will be much cleaner.
As an option, you could try to design your own search controller using only UISearchBar or even plain UITextField.
You could subclass UISearchController and add UITextField object. UISearchController conforms to UIViewControllerAnimatedTransitioning and UIViewControllerTransitioningDelegate protocols, where UITextFiled could be removed or added along with transition animations.
I hope this helps.
Update:
I have implemented approach I described under point 3. Here is how it works:
You can find code snippet here. Please note, that it is only code example, there are might be situations which are not handled. Check it twice then.
Create one UIView in XIB. name it searchView.
Add UIButton inside above UIView in same xib and name it btnSearch. Like below

Setup search controller in ViewDidLoad as below:
func setupSearchController() {
self.searchController = UISearchController(searchResultsController: nil)
self.searchController.delegate = self
self.searchController.searchBar.delegate = self
definesPresentationContext = true
self.searchController.dimsBackgroundDuringPresentation = false
self.searchController.searchBar.sizeToFit()
searchView.addSubview(self.searchController.searchBar)
self.searchController.searchBar.isHidden = true
self.searchController.searchBar.tintColor = UIColor.white
self.searchController.searchBar.showsCancelButton = false
UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).tintColor = session?.makeColor(fromHexString: TYPE_COLOR, alpha: 1.0)
self.searchController.searchBar.isTranslucent = false
self.searchController.searchBar.barTintColor = UIColor(red: 239.0 / 255.0, green: 135.0 / 255.0, blue: 1.0 / 255.0, alpha: 1.0)
}
This method will setup searchcontroller programatically inside searchview.
Now you just need to show searchcontroller programatically. add On click method of button in step 2. Call this below method name showsearchAnimation:
func ShowSeachAnimation() {
searchFrame = searchView.frame (add one global variable "searchFrame" in controller which saves searchView.frame so it will be used when cancel button clicked on search)
self.btnSearch.isHidden = true
var yAxis : CGFloat = 20
if #available(iOS 11 , *) {
yAxis = 8
} else {
yAxis = 20
}
searchView.translatesAutoresizingMaskIntoConstraints = true
searchView.frame = CGRect(x: 0, y: yAxis, width: view.frame.size.width, height: view.frame.size.height - yAxis)
self.searchController.searchBar.isHidden = false
self.searchController.searchBar.becomeFirstResponder()
self.searchController.searchBar.showsCancelButton = true
self.searchBar(self.searchController.searchBar, textDidChange: "")
searchView.layoutIfNeeded()
}
For hide searchbar, Add search hide method named "searchbarcancelbuttonclicked" in searchcontroller delegate:
func searchViewHideAnimation() {
self.removeNavigationBar()
self.navigationController?.isNavigationBarHidden = false
self.searchController.searchBar.text = " "
self.searchController.searchBar.isHidden = true
UIView.animate(withDuration: 0.3, animations: {() -> Void in
self.searchView.frame = self.searchFrame!
self.btnSearch.isHidden = false
}, completion: {(_ finished: Bool) -> Void in
self.searchView.layoutIfNeeded()
})
}
you can try to use my https://github.com/Blackjacx/SHSearchBar Which essentially will be your text field. You can add constraints to the left, right and top and adjust the top constraint when the text field editing begins. At the same time, you hide the navigation bar animated and gray out the background by using an overlay view. This way you have maximized control over your animations and this appüroach is not so difficult as it might sound.

UIview animation misbehaves when image of an unrelated button is changed

On my viewcontroller, I have a button to enable/disable audio using the following action:
func audioControl() {
playMusic = playMusic ? false : true // toggle
if (playMusic) {
startMusic()
audioBtn.setImage(UIImage(named: "musicOn"),for: .normal)
}
else {
stopMusic()
audioBtn.setImage(UIImage(named: "musicOff"),for: .normal)
}
}
The button toggles its image, as expected.
There is also an unrelated UIImageView object on the same VC which is being translated as follows:
UIView.animate(withDuration: 10.0, delay: 0.0, options: [ .curveLinear, .repeat ], animations: {
self.movingImg.center = CGPoint(x: screenSize.minX, y: screenSize.maxY)
})
This simple animation works fine by itself. However, as soon as the button is tapped, the movingImg object goes outside the boundary of screen and it is no longer visible.
I am not sure why a change of image on a button causes another object animation to misbehave. Any thoughts? I am using Swift 3.
Thank you.
After reading through discussions in a number of SO threads, I found that with the image change (along with any other layout changes), the viewcontroller's viewDidLayoutSubviews() gets called. As such, the followings are two things I did to resolve this issue:
Within the audioControl() method, I removed the animation of movingImg (i.e. movingImg.layer.removeAllAnimations()) before updating the button image.
I added viewDidLayoutSubviews() to my viewcontroller and started the animation of movingImg there.
Hopefully, this approach helps others too.

Resources