In an app that is based on a UINavigationController, the navigation controller's toolbar is turned on so that there is the UINavigationBar at the top and the UIToolbar at the bottom.
For the root view controller there is obviously no back button, and there is also no title (and no title view). I have set the title = nil.
This same root view controller has a lot of bar button items in the navigation bar's rightBarButtonItems. Since there is nothing else in the navigation bar apart from these items, I would like them to be centred and evenly spaced across the navigation bar. This way, they would look less crowded, be less likely to touch the wrong button, looks nicer in general, and would also match the look of the toolbar at the bottom.
In the bottom toolbar, this is easily achieved by using flexible space between each item (and before/after the first/last items in my case).
For the top navigation bar, flexible spaces (and fixed spaces) appears to be ignored.
How can I get my bar button items in the navigation bar to spread out across the entire bar when there is nothing else on the bar?
Or is the only option to hide the bar, and actually place an independent toolbar at the top in its place? (Would be a shame to have to resort to this, as the nav bar is already there and very nearly doing the job and is automatically managed.)
Here's how I've accomplished an approximation of using flexible spaces in a navigation bar to evenly space out all of the bar button items.
NB: As it stands, it only works for bar button items with images. Text-only items do not work, as it uses the image size to determine the widths of the items. It should not be too difficult to updated it to work with text or text/image items.
It works by summing the total widths of all the images in all the bar button items, and subtracting this from the size of the bar to get the amount of space remaining. Then divide this by the number of items (plus one as I want a space before and after all items) and adjust for screen scale.
Set this to the width of a new FIXED space item.
Add the items back in, with the new fixed space before, between and after each of them.
func updateNavbarItemSpacing(forBarWidth width: CGFloat) {
guard let oldItems = navigationItem.leftBarButtonItems else { return }
var nonSpaceItems: [UIBarButtonItem] = []
var usedWidth: CGFloat = 0.0
var itemCount:CGFloat = 0.0
for item in oldItems {
if let itemWidth = item.image?.size.width {
usedWidth += itemWidth
itemCount += 1
nonSpaceItems.append(item)
}
}
let spaceWidth = ( ( width - usedWidth) / itemCount + 1 ) / UIScreen.main.scale
let fixedSpace = UIBarButtonItem.fixedSpace(spaceWidth)
var newItems: Array<UIBarButtonItem> = [fixedSpace]
for item in nonSpaceItems {
newItems.append(item)
newItems.append(fixedSpace)
}
navigationItem.leftBarButtonItems = newItems
}
This function should be called whenever the items are updated, AND whenever the navbar size changes. Eg, don't forget to include it in the view controller's:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
updateNavbarItemSpacing(forBarWidth: size.width)
}
Working well in testing so far. Perfect in portrait mode. But in lasdcape orientation, I end up with a little extra space at the end. Yet to figure this one out.
I've found an even simpler way to achieve this now.
This version simply divides the available space between the number of items.
Subtracting 2 points seems to work better - without this the results are not as pleasing in a crowded toolbar. I guess this allows for a tiny margin between each item?
Note that if the text/image of some items is wider than other items, then the spacing between each will not be even. So this method is best suited to items that have similar widths.
func updateNavbarItemSpacing(forBarWidth barWidth: CGFloat) {
guard let items = navigationItem.leftBarButtonItems else { return }
if items.count == 0 { return }
let itemWidth = barWidth / CGFloat(items.count) - 2
for item in items {
item.width = itemWidth
}
navigationItem.leftBarButtonItems = items
}
Call the above after setting the navigationItem's left toobar items.
Also, for correct behaviour when rotating the device, call it in the view controller's:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
updateNavbarItemSpacing(forBarWidth: size.width)
}
Related
I have an app with a build target of IOS 14 that is causing a problem regarding automatic positioning of the view on keyboard show.
I have a UITextView that is draggable and can be positioned partially outside of the main view that it sits within. If the field is large enough then it will extend beyond the parent view and safe area also. The parent view has clipsToBounds set as true so the overflow of the text view is not visible.
The problem is when the text field is positioned so that its right hand side is outside of the safe area and the keyboard is presented, the screen automatically scrolls left to include the far right edge of the text view, even though it is not visible due to clipsToBounds being set on its parent. I need to disable the behaviour that is causing this to happen but can't find anything that covers this for UIKit.
See below for a visual example. Can anybody please help?
Image 1
Image 2
Edit:
The structure of the screen is:
View Controller:
.....UICollectionView:
..........UICollectionViewCell:
...............UIView:
....................Elements (UITextView in this case)
func calculateCarouselOffset(formHeight: CGFloat) -> CGAffineTransform {
let carouselOffset: CGAffineTransform!
let currentElementMaxY = returnCurrentElementMaxY()
let elementMaxYTransformRemoved = currentElementMaxY + -self.scalingCarousel.transform.ty
let newFormOriginY = safeAreaFrame.height - formHeight
let topOfFormMargin: CGFloat = 20
if (newFormOriginY - topOfFormMargin) < elementMaxYTransformRemoved {
// Form will overlap element - move carousel view to compensate
let oldToNewLocDist = (newFormOriginY - topOfFormMargin) - currentElementMaxY
let moveScreenBy = self.scalingCarousel.transform.ty + oldToNewLocDist
carouselOffset = CGAffineTransform(translationX: 0, y: moveScreenBy)
} else {
// Form will not overlap element - reset carousel view
carouselOffset = self.formDeactivate
}
return carouselOffset
}
And it is called as below:
func textViewDidChage() {
let backgorundTransform = calculateCarouselOffset(formHeight: currentElementFormHeight)
let modifyBackground = UIViewPropertyAnimator(duration: 0.2, curve: .linear, animations: {
self.scalingCarousel.transform = backgorundTransform
})
modifyBackground.startAnimation()
}
It looks like this is (possibly new?) built-in behaviour for text fields. I reproduced this both with a collection view controller and a view controller holding a collection view. The text field moves itself to visible like this:
I found this by adding a symbolic breakpoint on contentOffset and then making a field editable - there are a lot of calls before you get to this point because it's also adjusting things for the keyboard coming up.
Unfortunately in your case, I think the scroll view is moving the text field's visible bounds into the visible area, which means you're scrolling horizontally since the text field is off screen.
You can't override scrollTextFieldToVisibleIfNecessary as it is private API. There are probably some hacks you can do by overriding becomeFirstResponder but they seem quite likely to either not work, or break other things.
I'd like to implement a nav style like what is found in the "Add Reminder" view controller from Apple's Reminders (iOS 14). I've tried hooking into the scrollview delegate methods but I'm not sure how to change the alpha of the default nav bar background/shadow image.
I've tried changing the nav bar style on scroll and, while that works, it doesn't fade in/out like in the example. That makes me think the answer lies manipulating the alpha value. Thanks in advance!
I've found a (hacky) solution that works in iOS 14 (untested in other versions). It makes an assumption about the private view structure of UINavigationBar, so there's no guarantee that it will work in future iOS versions, but it's unlikely to crash - the worst that should happen is that the bar will fail to hide, or only partially hide.
Assuming that you are placing the code inside a UIViewController subclass that it acting as the delegate for a UITableView, UICollectionView or UIScrollView, the following should work:
override func viewDidLoad() {
super.viewDidLoad()
// this hides the bar initially
self.navigationController?.navigationBar.subviews.first?.alpha = 0
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let navigationController = self.navigationController else { return }
let navBarHeight = navigationController.navigationBar.frame.height
let threshold: CGFloat = 20 // distance from bar where fade-in begins
let alpha = (scrollView.contentOffset.y + navBarHeight + threshold) / threshold
navigationController.navigationBar.subviews.first?.alpha = alpha
}
The magic threshold value is a little hard to explain, but it's basically the distance from the bar at which the fade in will start. A value of 20 means the bar starts to fade in when the scrollView content is 20 points away. A value of 0 would mean the bar snaps straight from fully transparent to fully opaque the moment the scrollView content touches it.
My VC starts with stackView attached with Align Bottom to Safe Area .
I have tabBar, but in the beginning is hidden tabBar.isHidden = true.
Later when the tabBar appears, it hides the stackView
So I need function that refresh constraints after tabBar.isHidden = false
When I start the app with tabBar.isHidden = false the stackView is shown properly.
Tried with every function like: stackView.needsUpdateConstraints() , updateConstraints() , setNeedsUpdateConstraints() without success.
Now I'm changing the bottom programatically, but when I switch the tabBarIndex and return to that one with changed bottom constraints it detects the tabBar and lifts the stackView under another view (which is not attached with constraints). Like is refreshing again the constraints. I'm hiding and showing this stackView with constrains on/off screen.
I need to refresh constraints after tabBar.isHidden = false, but the constraints don't detect the appearance of the tabBar.
As I mention switching between tabBars fixes the issue, so some code executes to detecting tabBar after the switch. Is anyone know this code? I tried with calling the methods viewDidLayoutSubviews and viewWillLayoutSubviews without success... Any suggestions?
This amateur approach fixed my bug... :D
tabBarController!.selectedIndex = 1
tabBarController!.selectedIndex = 0
Or with an extension
extension UITabBarController {
// Basically just toggles the tabs to fix layout issues
func forceConstraintRefresh() {
// Get the indices we need
let prevIndex = selectedIndex
var newIndex = 0
// Find an unused index
let items = viewControllers ?? []
find: for i in 0..<items.count {
if (i != prevIndex) {
newIndex = i
break find
}
}
// Toggle the tabs
selectedIndex = newIndex
selectedIndex = prevIndex
}
}
Usage (called when switching dark / light mode):
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
tabBarController?.forceConstraintRefresh()
}
If you want to update view's layout, you can try layoutIfNeeded() function.
after updating stackView constraints call this method:
stackView.superview?.layoutIfNeeded()
Apple's Human Interface Guidelines indicate that one should not mess around with the Tab Bar, which is why (I'm guessing) setting tabBar.isHidden doesn't properly update the rest of the view hierarchy.
Quick searching comes up with various UITabBarController extensions for showing / hiding the tab bar... but they all appear to push the tabBar down off-screen, rather than setting its .isHidden property. May or may not be suitable for your use.
I'm assuming from your comments that your VC in tab index 0 has a button (or some other action) to show / hide the tabBar?
If so, here is an approach that may do the job....
Add this enum in your project:
enum TabBarState {
case toggle, show, hide
}
and put this func in that view controller:
func showOrHideTabBar(state: TabBarState? = .toggle) {
if let tbc = self.tabBarController {
let b: Bool = (state == .toggle) ? !tbc.tabBar.isHidden : state == .hide
guard b != tbc.tabBar.isHidden else {
return
}
tbc.tabBar.isHidden = b
view.frame.size.height -= 0.1
view.setNeedsLayout()
view.frame.size.height += 0.1
}
}
You can call it with:
// default: toggles isHidden
showOrHideTabBar()
// toggles isHidden
showOrHideTabBar(state: .toggle)
// SHOW tabBar (if it's hidden)
showOrHideTabBar(state: .show)
// HIDE tabBar (if it's showing)
showOrHideTabBar(state: .hide)
I would expect that simply pairing .setNeedsLayout() with .layoutIfNeeded() after setting the tabBar's .isHidden property should do the job, but apparently not.
The quick frame height change (combined with .setNeedsLayout()) does trigger auto-layout, though, and the height change is not visible.
NOTE: This is the result of very brief testing, on one device and one iOS version. I expect it will work across devices and versions, but I have not done complete testing.
After reading a few articles about custom UITabBarControllers, I am left more confused than before I even started doing the research in the first place.
My goal is to create a custom TabBar with 3 important properties:
No text, just icons
The active icon is marked by a circle filled with a color behind it, and therefore needs a different icon color
Here's what I am trying to achieve:
I've been able to remove the text and center the icon by following another StackOverflow answer (Remove tab bar item text, show only image), although the solution seems like a hack to me.
How would I go about creating a circle behind the item and change the active item's color?
Also, would someone mind explaining the difference between the XCode inspector sections "Tab Bar Item" and "Bar Item", which appear directly under each other?
The first step is simple: leaving the title property of the UITabbarItem empty should hide the label.
Your second step can actually be broken down into two steps: changing the color of the icon and adding a circle behind it.
The first step here is simple again: you can set a different icon to use for the currently selected ViewController (I use Storyboards, this process is pretty straightforward). What you'd do is add a white version of the icon to be shown when that menu option is selected.
The final step is displaying the circle. To do this, we'll need the following information:
Which item is currently selected?
What is the position of the icon on the screen?
The first of these two is pretty easy to find out, but the second poses a problem: the icons in a UITabBar aren't spaced around the screen equally, so we can't just divide the width of the tabbar by the amount of items in it, and then take half of that to find the center of the icons. Instead, we will subclass UITabBarController.
Note: the tabBar property of a UITabBarController does have a .selectionIndicatorImage property. You can assign an image to this and it will be shown behind your icon. However, you can't easily control the placement of this image, and that is why we still resort to subclassing UITabBarController.
class CircledTabBarController: UITabBarController {
var circle: UIView?
override func viewDidLoad() {
super.viewDidLoad()
let numberOfItems = CGFloat(tabBar.items!.count)
let tabBarItemSize = CGSize(width: (tabBar.frame.width / numberOfItems) - 20, height: tabBar.frame.height)
circle = UIView(frame: CGRect(x: 0, y: 0, width: tabBarItemSize.height, height: tabBarItemSize.height))
circle?.backgroundColor = .darkGray
circle?.layer.cornerRadius = circle!.frame.width/2
circle?.alpha = 0
tabBar.addSubview(circle!)
tabBar.sendSubview(toBack: circle!)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let index = -(tabBar.items?.index(of: tabBar.selectedItem!)?.distance(to: 0))!
let frame = frameForTabAtIndex(index: index)
circle?.center.x = frame.origin.x + frame.width/2
circle?.alpha = 1
}
override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
let index = -(tabBar.items?.index(of: item)?.distance(to: 0))!
let frame = frameForTabAtIndex(index: index)
self.circle?.center.x = frame.origin.x + frame.width/2
}
func frameForTabAtIndex(index: Int) -> CGRect {
var frames = tabBar.subviews.compactMap { (view:UIView) -> CGRect? in
if let view = view as? UIControl {
for item in view.subviews {
if let image = item as? UIImageView {
return image.superview!.convert(image.frame, to: tabBar)
}
}
return view.frame
}
return nil
}
frames.sort { $0.origin.x < $1.origin.x }
if frames.count > index {
return frames[index]
}
return frames.last ?? CGRect.zero
}
}
Now use this subclass of UITabBarController instead of the base class.
So why this approach over simply changing the icon to a circled one? Because you can do many different things with this. I wrote an article about animating the UITabBarController in a similar manner, and if you like, you can easily use above implementation to add animation to yours too.
The easiest and actually cleanest way to do it is to design your icons and import them as images to the .xcassets folder. Then you can just set the different icons for the different states for each of the viewControllers with:
ViewController.tabBarItem = UITabBarItem(title: "", image: yourImage.withRenderingMode(.alwaysOriginal), selectedImage: yourImage)
your selected image will be the one with the circle and the image will be without. It is way easier than manipulating the images in xcode and it is also less expensive since the compiler only has to render the images and doesn't have to manipulate them.
About the other question UIBarItem is
An abstract superclass for items that can be added to a bar that appears at the bottom of the screen.
UITabBarItem is a subclass of UIBarItem to provide extra funtionality.
I'm working with Cocoa and I create my views in code (no IB) and I'm hitting an issue with NSSplitView.
I have a NSSplitView that I configure in the following way in my view controller, in Swift:
override func viewDidLoad() {
super.viewDidLoad()
let splitView = NSSplitView()
splitView.isVertical = true
splitView.addArrangedSubview(self.createLeftPanel())
splitView.addArrangedSubview(self.createRightPanel())
splitView.adjustSubviews()
self.view.addSubview(splitView)
...
}
The resulting view shows the two subviews and the divider for the NSSplitView, and one view is wider than the other. When I drag the diver to change the width, as soon as I release the mouse, the divider goes back to its original position, as if pulled back by a "spring".
I can't resize the two subviews; the right one always keeps a fixed size. However, nowhere in the code I fix the width of that subview, or any of its content, to a constant.
What I would like to achieve instead is that the right view size is not fixed, and that if I drag the divider at halfway through, the subviews will resize accordingly and end up with the same width.
This is a screen recording of the problem:
Edit: here is how I set the constraints. I'm using Carthography, because otherwise setting constraints in code is extremely verbose beyond the most simple cases.
private func createLeftPanel() -> NSView {
let view = NSView()
let table = self.createTable()
view.addSubview(table)
constrain(view, table) { view, table in // Cartography magic.
table.edges == view.edges // this just constraints table.trailing to
// view.trailing, table.top to view.top, etc.
}
return view
}
private func createRightPanel() -> NSView {
let view = NSView()
let label = NSTextField(labelWithString: "Name of item")
view.addSubview(label)
constrain(view, label) { view, label in
label.edges == view.edges
}
return view
}