swiftswiftuiuibezierpathtabbarswiftui-tabview

How to make Custom TabBar with upper curved that stays in Center in SwiftIUI?


I am new to SwiftUI and have completed half of my TabBar UI. However, I am stuck on adding a minor curve to both sides of the center button, similar to the example image.

Expected TabBar UI

I'm unsure how to add a minor curve on both sides of the center button? This is what I have achieved so far:

What I have done

Below is my code for TabBar:

import SwiftUI

struct ContentView: View {
    // MARK: - HIDING NATIVE TAB BAR
    init(){
        UITabBar.appearance().isHidden = true
    }
    
    var body: some View {
        VStack {
            Spacer()
            TabBarShape()
                .fill(Color.white)
                .frame(height: 80)
                .shadow(color: Color.black.opacity(0.4), radius: 2, x: 0, y: -1)
                .overlay(
                    ZStack {
                        Button(action: {
                            print("Create Button Action")
                        }, label: {
                            Image("plus_icon")
                                .frame(width: 60, height: 60, alignment: .center)
                                .background(Color.custom64B054Color)
                                .cornerRadius(30)
                        }).offset(x: 0, y: -36)
                        Text("Create")
                            .padding(.top, 32)
                        HStack(spacing: 0) {
                            TabBarItem(iconName: "house.fill", action: {})
                            TabBarItem(iconName: "person.fill", action: {})
                        }
                            .frame(height: 80)
                    }
                )
        }.ignoresSafeArea()
        
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

 // MARK: - TabBar Shape
struct TabBarShape: Shape {
    // Constants used for the shape
    private enum Constants {
        static let cornerRadius: CGFloat = 20
        static let buttonRadius: CGFloat = 30
        static let buttonPadding: CGFloat = 9
    }
    
    // Function to define the shape's path
    func path(in rect: CGRect) -> Path {
        let path = UIBezierPath()
        
        // Move to the starting point at the bottom-left corner
        path.move(to: .init(x: 0, y: rect.height))
        
        // Add a line to the upper-left corner, leaving space for the corner radius
        path.addLine(to: .init(x: 0, y: rect.height - Constants.cornerRadius))
        
        // Add a quarter-circle in the upper-left corner
        path.addArc(withCenter: .init(x: Constants.cornerRadius, y: Constants.cornerRadius),
                    radius: Constants.cornerRadius,
                    startAngle: CGFloat.pi,
                    endAngle: -CGFloat.pi/2,
                    clockwise: true)
        
        // Calculate the end point for the line before the first button
        let lineEnd = rect.width/2 - 2 * Constants.buttonPadding - Constants.buttonRadius
        
        // Add a line to the calculated end point
        path.addLine(to: .init(x: lineEnd, y: 0))
        
        // Add a quarter-circle for the first button
        path.addArc(withCenter: .init(x: lineEnd, y: Constants.buttonPadding),
                    radius: Constants.buttonPadding,
                    startAngle: 0,
                    endAngle: -CGFloat.pi/2,
                    clockwise: true)
        
        // Add a half-circle for the first button
        path.addArc(withCenter: .init(x: rect.width/2, y: 0),
                    radius: Constants.buttonPadding + Constants.buttonRadius,
                    startAngle: 0,
                    endAngle: CGFloat.pi,
                    clockwise: false)
                
        // Calculate the start point for the line after the first button
        let lineStart = rect.width/2 + 2 * Constants.buttonPadding - Constants.buttonRadius
        
        // Add a quarter-circle for the second button
        path.addArc(withCenter: .init(x: lineStart, y: Constants.buttonPadding),
                    radius: Constants.buttonPadding,
                    startAngle: -CGFloat.pi/2,
                    endAngle: -CGFloat.pi/2,
                    clockwise: true)
        
        // Add a line to the calculated start point for the second button
        path.addLine(to: .init(x: rect.width - Constants.cornerRadius, y: 0))
        
        // Add a quarter-circle in the upper-right corner
        path.addArc(withCenter: .init(x: rect.width - Constants.cornerRadius, y: Constants.cornerRadius),
                    radius: Constants.cornerRadius,
                    startAngle: -CGFloat.pi/2,
                    endAngle: 0,
                    clockwise: true)
        
        // Add a line to the bottom-right corner
        path.addLine(to: .init(x: rect.width, y: rect.height))
        
        // Close the path to complete the shape
        path.close()
        
        // Convert the UIBezierPath to a SwiftUI Path
        return Path(path.cgPath)
    }
}

 // MARK: - TabBar Item
struct TabBarItem: View {
    let iconName: String
    let action: () -> Void
    
    var body: some View {
        Button(action: action) {
            Image(systemName: iconName)
                .font(.system(size: 24))
                .foregroundColor(.blue)
                .padding(20)
        }
        .frame(maxWidth: .infinity)
    }
}

It would be great if anyone could help. Also Could anyone guide me if this is the correct approach for my expected TabBar UI?

Thank You!


Solution

  • I had a go at implementing this shape using the same technique of building the path as was used in the answer to TabBar Customisation in SwiftUI (it was my answer).

    I am not familiar with a UIBezierPath, so I stuck to Path instead.

    Here is the revised TabBarShape:

    struct TabBarShape: Shape {
    
        // Constants used for the shape
        private enum Constants {
            static let cornerRadius: CGFloat = 20
            static let smallCornerRadius: CGFloat = 15
            static let buttonRadius: CGFloat = 30
            static let buttonPadding: CGFloat = 9
        }
    
        // Function to define the shape's path
        func path(in rect: CGRect) -> Path {
            var path = Path()
    
            // Move to the starting point at the bottom-left corner
            var x = rect.minX
            var y = rect.maxY
            path.move(to: CGPoint(x: x, y: y))
    
            // Add the rounded corner on the top-left corner
            x += Constants.cornerRadius
            y = Constants.buttonRadius + Constants.cornerRadius
            path.addArc(
                center: CGPoint(x: x, y: y),
                radius: Constants.cornerRadius,
                startAngle: .degrees(180),
                endAngle: .degrees(270),
                clockwise: false
            )
            // Add a small corner leading to the main half-circle
            x = rect.midX - Constants.buttonRadius - (Constants.buttonPadding / 2) - Constants.smallCornerRadius
            y = Constants.buttonRadius - Constants.smallCornerRadius
            path.addArc(
                center: CGPoint(x: x, y: y),
                radius: Constants.smallCornerRadius,
                startAngle: .degrees(90),
                endAngle: .degrees(35), // 0
                clockwise: true
            )
            // Add the main half-circle
            x = rect.midX
            y += Constants.smallCornerRadius + Constants.buttonPadding
            path.addArc(
                center: CGPoint(x: x, y: y),
                radius: Constants.buttonRadius + Constants.buttonPadding,
                startAngle: .degrees(215), // 180
                endAngle: .degrees(325), // 0
                clockwise: false
            )
            // Add a trailing small corner
            x += Constants.buttonRadius + (Constants.buttonPadding / 2) + Constants.smallCornerRadius
            y = Constants.buttonRadius - Constants.smallCornerRadius
            path.addArc(
                center: CGPoint(x: x, y: y),
                radius: Constants.smallCornerRadius,
                startAngle: .degrees(145), // 180
                endAngle: .degrees(90),
                clockwise: true
            )
            // Add the rounded corner on the top-right corner
            x = rect.maxX - Constants.cornerRadius
            y = Constants.buttonRadius + Constants.cornerRadius
            path.addArc(
                center: CGPoint(x: x, y: y),
                radius: Constants.cornerRadius,
                startAngle: .degrees(270),
                endAngle: .degrees(0),
                clockwise: false
            )
            // Connect the bottom corner
            x = rect.maxX
            y = rect.maxY
            path.addLine(to: CGPoint(x: x, y: y))
    
            // Close the path to complete the shape
            path.closeSubpath()
            return path
        }
    }
    

    You will notice that there is no need to draw lines between arcs, because they are added automatically.

    The smaller corners leading in and out of the main half-circle are drawn as separate arcs. To smooth the join, these arcs and the main half-circle don't go all the way to 90 degrees. These partial angles were found with a little trial-and-error. I also noticed that the height of the half circle in your target screenshot was less than half the height of the button with padding, which is why the center of the + does not align with the top edge of the background area. I used the size of the button padding for this height adjustment and half of the padding for adjusting the x-positions where the small corners begin and end.

    Here is how the shape can be used in an adapted version of your main body. Some notes:

    var body: some View {
        GeometryReader { proxy in
            VStack {
                Spacer()
                HStack(alignment: .bottom) {
                    TabBarItem(label: "Schedule", iconName: "house.fill") {}
                    Text("Create")
                    TabBarItem(label: "Profile", iconName: "person.fill") {}
                }
                .font(.footnote)
                .padding(.top, 42)
                .overlay(alignment: .top) {
                    Button {
                        print("Create Button Action")
                    } label: {
                        Image(systemName: "plus") // "plus_icon"
                            .resizable()
                            .scaledToFit()
                            .padding()
                            .frame(width: 60, height: 60)
                            .foregroundStyle(.white)
                            .background {
                                Circle()
                                    .fill(.green) // custom64B054Color
                                    .shadow(radius: 3)
                            }
                    }
                    .padding(9)
                }
                .padding(.bottom, max(8, proxy.safeAreaInsets.bottom))
                .background {
                    TabBarShape()
                        .fill(.white)
                        .shadow(radius: 3)
                }
            }
            .ignoresSafeArea(edges: .bottom)
        }
    }
    

    I also made some small changes to TabBarItem, to include a label and omit the padding:

    struct TabBarItem: View {
        let label: String
        let iconName: String
        let action: () -> Void
    
        var body: some View {
            Button(action: action) {
                VStack(spacing: 4) {
                    Image(systemName: iconName)
                        .font(.system(size: 24))
                        .foregroundColor(.blue)
                    Text(label)
                }
            }
            .frame(maxWidth: .infinity)
        }
    }
    

    Here's how it all looks:

    Screenshot

    Hope it helps.