Custom TabBarController with active circle - swift

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.

Related

UINavigationBar using flexible spaces to distribute bar button items evenly

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)
}

How I can show/hide view inside another view with height changing of root swift?

I have two views one inside another, it looks like picture below:
when I press on orange arrow I would like to show/hide view above grey line and grey line too. I did it by such way:
#objc func showHide(tapGestureRecognizer: UITapGestureRecognizer)
{
let tappedImage = tapGestureRecognizer.view as! UIImageView
UIView.animate(withDuration: 0.2, animations: { () -> Void in
self.jobDataView.isHidden = !self.jobDataView.isHidden
self.view.layoutIfNeeded()
})
tappedImage.image = self.jobDataView.isHidden ? UIImage(systemName: "arrow.down"):UIImage(systemName: "arrow.up")
}
and my view above gray line can be hidden and shown. But root view doesn't change its' height and it has similar sizes before and after btn click. I tried to add constraint of height, but I it didn't solve my problem. Maybe someone knows how to solve my problem?
You need to use UIStackView either through Storyboard or from code. when you hide subview inside stackview it will automatically change stackview height. what you need to do is to make stackView distribution = fill and use vertical stack...
Hide view on button tap and when you need to show it .. add it in stack at. 0th index ..

Swift - Programmatically refresh constraints

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.

How do I add NSButtons from the top instead of from the bottom?

I am currently building a navigation left pane where buttons from the side are added dynamically. I have a NSView subclass that facilitates this
func addButton(_ service: String) {
let btn = NSButton()
btn.bezelStyle = .shadowlessSquare
btn.frame.size = NSSize(width: 48, height: 48)
btn.image = NSImage(named: service)
btn.imageScaling = .scaleAxesIndependently
btn.frame.origin = CGPoint(x: 0, y: self.currentY)
self.addSubview(btn)
self.currentY += 58
}
It works as desired but buttons are added from the bottom instead of the top:
Resulting App Screenshot
How do I make it so that new buttons are added from the top?
For reference, here's my storyboard with the "Servi" view containing the buttons.
Left Pane Storyboard
Y goes from bottom to top on macOS (the bottom is 0). So don't start with currentY at 0 or 10 or whatever you're starting with. Start with currentY set to one button height less than the view height, and then subtract 58 each time instead of adding.
So, you're getting something like this:
But if you do what I'm describing, you'll get something like this:
You could flip your view so that the coordinate system starts at the top left instead. Just create a new NSView subclass and override isFlipped
class FlippedView: NSView {
override var flipped:Bool {
get {
return true
}
}
}
Then add the new buttons to an instance of flippedview instead.

Dragging NSSplitView divider does not resize views

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
}