iosswiftuitabbaruibezierpath

Animate tab bar shape created using UIBezierPath() based on selected index


I have created a tab Bar shape using UIBezierPath(). Im new to creating shapes so in the end I got the desired shape closer to what I wanted, not perfect but it works. It's a shape which has a wave like top, so on the left side there is a crest and second half has a trough. This is the code that I used to create the shape:

 func createPath() -> CGPath {
        let height: CGFloat = 40.0 // Height of the wave-like curve
        let extraHeight: CGFloat = -20.0 // Additional height for top left and top right corners
        let path = UIBezierPath()
        let width = self.frame.width
        // Creating a wave-like top edge for tab bar starting from left side
        path.move(to: CGPoint(x: 0, y: extraHeight)) // Start at top left corner with extra height
        path.addQuadCurve(to: CGPoint(x: width/2, y: extraHeight), controlPoint: CGPoint(x: width/4, y: extraHeight - height))
        path.addQuadCurve(to: CGPoint(x: width, y: extraHeight), controlPoint: CGPoint(x: width*3/4, y: extraHeight + height))
        path.addLine(to: CGPoint(x: self.frame.width, y: self.frame.height))
        path.addLine(to: CGPoint(x: 0, y: self.frame.height))
        path.close()
        return path.cgPath
    }

Above im using -20 so that shape stays above bounds of tab bar and second wave's trough stays above the icons of tab bar. Here is the desired result:

A tab bar with four tabs: a house icon, a cloud-and-rain icon, a speedometer icon, and a head with headphones icon. The window background is red. The tab bar background is white and the top edge of the tab bar is curved like one cycle of a sine wave. The house icon is selected.

This was fine until I was asked to animate the shape on pressing tab bar items. So if I press second item, the crest should be above second item and if fourth, then it should be above fourth item. So I created a function called updateShape(with selectedIndex: Int) and called it in didSelect method of my TabBarController. In that im passing index of the selected tab and based on that creating new path and removing old and replacing with new one. Here is how im doing it:

    func updateShape(with selectedIndex: Int) {
        let shapeLayer = CAShapeLayer()
        let height: CGFloat = 40.0
        let extraHeight: CGFloat = -20.0
        let width = self.frame.width

        let path = UIBezierPath()
        path.move(to: CGPoint(x: 0, y: extraHeight))
        if selectedIndex == 0 {
            path.addQuadCurve(to: CGPoint(x: width / 2, y: extraHeight), controlPoint: CGPoint(x: width / 4, y: extraHeight - height))
            path.addQuadCurve(to: CGPoint(x: width, y: extraHeight), controlPoint: CGPoint(x: width / 2 + width / 4, y: extraHeight + height))
        }
        else if selectedIndex == 1 {
            path.addQuadCurve(to: CGPoint(x: width / 2 + width / 4, y: extraHeight), controlPoint: CGPoint(x: width / 4 + width / 4, y: extraHeight - height))
            path.addQuadCurve(to: CGPoint(x: width, y: extraHeight), controlPoint: CGPoint(x: width * 3 / 4 + width / 4, y: extraHeight + height))
        }
        else if selectedIndex == 2 {
            let xShift = width / 4
            path.addQuadCurve(to: CGPoint(x: width / 2 + xShift, y: extraHeight), controlPoint: CGPoint(x: width / 8 + xShift, y: extraHeight + height))
            path.addQuadCurve(to: CGPoint(x: width, y: extraHeight), controlPoint: CGPoint(x: width * 7 / 8 + xShift, y: extraHeight - height))
        }
        else {
            path.addQuadCurve(to: CGPoint(x: width / 2, y: extraHeight), controlPoint: CGPoint(x: width / 4, y: extraHeight + height))
            path.addQuadCurve(to: CGPoint(x: width, y: extraHeight), controlPoint: CGPoint(x: width / 2 + width / 4, y: extraHeight - height))
        }
        path.addLine(to: CGPoint(x: width, y: self.frame.height))
        path.addLine(to: CGPoint(x: 0, y: self.frame.height))
        path.close()

        shapeLayer.path = path.cgPath
        shapeLayer.fillColor = UIColor.secondarySystemBackground.cgColor

        if let oldShapeLayer = self.shapeLayer {
            self.layer.replaceSublayer(oldShapeLayer, with: shapeLayer)
        } else {
            self.layer.insertSublayer(shapeLayer, at: 0)
        }

        self.shapeLayer = shapeLayer
    }

And calling it like this:

func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
        let tabBar = tabBarController.tabBar as! CustomTabBar
        guard let index = viewControllers?.firstIndex(of: viewController) else {
            return
        }

        tabBar.updateShape(with: index)
    }

This is working fine as you can see below but the problem is I learned creating shapes just now and creating that wave based on width of screen so the crest and trough are exactly half the width of frame so I was able to do it for FourthViewController too and got this:

The same tab bar as before. Now the window background is brown, the head-with-headphones tab is selected, and the sine wave is mirror-imaged so the trough is on the left and the peak is on the right.

But the problem arises for remaining 2 indices. Im not able to create same wave which looks like the crest is moving above second or third item instead I get something like this:

The same tab bar as above. Now the speedometer tab is selected, the window background is green, and the sine wave is shifted so the trough is roughly over the cloud-with-rain icon and the peak is off the right side of the screen.

It doesn't look like other to waves showing the hump over third item. Also my code is strictly based on 4 items and was wondering if Im asked to add 1 more item so tab bar has 5 or 6 items, it would be trouble. Is there any way to update my function that creates new shapes based on index of tab bar or can anyone help me just create shapes for remaining two items? The shape should look same just the hump should exactly be over the selected item.


Solution

  • So you want something like this:

    A tab bar with four icons. The top of the tab bar is an attenuated sinusoidal curve with a major peak above the selected tab icon. As different tabs are selected, the curve animates to keep the peak above the selected tab.

    Here's how I did it. First, I made a WaveView that draws this curve:

    A curve with a peak in the center and a smaller peaks on each side.

    Notice that the major peak exactly in the center. Here's the source code:

    class WaveView: UIView {
        var trough: CGFloat {
            didSet { setNeedsDisplay() }
        }
    
        var color: UIColor {
            didSet { setNeedsDisplay() }
        }
    
        init(trough: CGFloat, color: UIColor = .white) {
            self.trough = trough
            self.color = color
    
            super.init(frame: .zero)
    
            contentMode = .redraw
            isOpaque = false
        }
    
        override func draw(_ rect: CGRect) {
            guard let gc = UIGraphicsGetCurrentContext() else { return }
    
            let bounds = self.bounds
            let size = bounds.size
            gc.translateBy(x: bounds.origin.x, y: bounds.origin.y)
    
            gc.move(to: .init(x: 0, y: size.height))
    
            gc.saveGState(); do {
                // Transform the geometry so the bounding box of the curve
                // is (-1, 0) to (+1, +1), with the y axis going up.
                gc.translateBy(x: bounds.midX, y: trough)
                gc.scaleBy(x: bounds.size.width * 0.5, y: -trough)
    
                // Now draw the curve.
                for x in stride(from: -1, through: 1, by: 2 / size.width) {
                    let y = (cos(2.5 * .pi * x) + 1) / 2 * (1 - x * x)
                    gc.addLine(to: .init(x: x, y: y))
                }
            }; gc.restoreGState()
    
            // The geometry is restored.
            gc.addLine(to: .init(x: size.width, y: size.height))
            gc.closePath()
    
            gc.setFillColor(UIColor.white.cgColor)
            gc.fillPath()
        }
    
        required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    }
    

    Then, I made a subclass of UITabBarController named WaveTabBarController that creates a WaveView and puts it behind the tab bar. It makes the WaveView exactly twice the width of the tab bar, and sets the x coordinate of the WaveView's frame such that the peak is above the selected tab item. Here's the source code:

    class WaveTabBarController: UITabBarController {
        let waveView = WaveView(trough: 20)
    
        override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
            if superclass!.instancesRespond(to: #selector(UITabBarDelegate.tabBar(_:didSelect:))) {
                super.tabBar(tabBar, didSelect: item)
            }
            view.setNeedsLayout()
        }
    
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
    
            let shouldAnimate: Bool
    
            if waveView.superview != view {
                view.insertSubview(waveView, at: 0)
                shouldAnimate = false
            } else {
                shouldAnimate = true
            }
    
            let tabBar = self.tabBar
            let w: CGFloat
    
            if let selected = tabBar.selectedItem, let items = tabBar.items {
                w = (CGFloat(items.firstIndex(of: selected) ?? 0) + 0.5) / CGFloat(items.count) - 1
            } else {
                w = -1
            }
    
            let trough = waveView.trough
            let tabBarFrame = view.convert(tabBar.bounds, from: tabBar)
            let waveFrame = CGRect(
                x: tabBarFrame.origin.x + tabBarFrame.size.width * w,
                y: tabBarFrame.origin.y - trough,
                width: 2 * tabBarFrame.size.width,
                height: tabBarFrame.size.height + trough
            )
    
            guard waveFrame != waveView.frame else {
                return
            }
    
            if shouldAnimate {
                // Don't animate during the layout pass.
                DispatchQueue.main.async { [waveView] in
                    UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
                        waveView.frame = waveFrame
                    }
                }
            } else {
                waveView.frame = waveFrame
            }
        }
    }