Search code examples
swiftswiftui

How to change a button's appearance depending on its selection state


I'm trying to make the button look like this.

enter image description here

That is, the button will change its display state as it is clicked, somewhat similar to the TabBar. When the Bottom is activated, a gradient background will appear, the border on the Bottom disappears, and the corresponding View is displayed below. Both Buttons are done in this way to switch.

I planned to implement it by separating the Button and the View, but I encountered difficulties.

When I implemented it with SwiftUI, I added a Rectangle in the background that was larger than the front as a specific side and rounded border. The result was similar to the original one, except that the rounded corners were a little weird.

The implementation status is as follows:

enter image description here

These two Buttons are placed in the same Hstack. At first, I was setting by HStack(spacing: 0) {} to eliminate the preset spacing between buttons, but it seems that the Rectangle set in the background is not considered an entity, so I set the spacing to the border width I want, which is 3. But I suspect this is the best method. In addition, I refer to https://www.devtechie.com/community/public/posts/151937-round-specific-corners-in-swiftui for the method of customizing rounded corners.

I have implemented them in storyboards, but due to the different ways SwiftUI operates, it is difficult for me to replicate them. I would like to know if there is a way to improve the slightly thicker rounded corners. And how to let the Bottom border implement it to look like being molded in one piece with Button, and if is there any better way that could be improved for now.

The program code is as follows:

HStack(spacing: 0) {
    Button(action: {
        
    }){
        Text("行程簡介")
            .font(.title3)
            .fontWeight(.bold)
            .foregroundColor(Color("DesignMiddleMutedOrange"))
            .multilineTextAlignment(.center)
            .padding(.horizontal, 34.0)
        
    }
    .DetailButtonisOnActive()
    
    
    Button(action: {
        
    }){
        Text("Q&A")
            .font(.title3)
            .fontWeight(.bold)
            .foregroundColor(Color("DesignMiddleMutedOrange"))
            .multilineTextAlignment(.center)
            .padding(.horizontal, 34.0)
    }
    .QAButtonisOffActive()
    
    Spacer()
}

Two button's styles:

extension Button {
    func DetailButtonisOnActive() -> some View {
        self
            .frame(width: 150, height: 52, alignment: .leading)
            .background(
                LinearGradient(colors: [Color("#FFFAE6"), Color("DesignLightYellow")],
                               startPoint: .top,
                               endPoint: .bottom)
            )
            .roundedCorner(12, corners: .topRight)
            .background(
                Rectangle()
                .roundedCorner(12, corners: .topRight)
                .frame(width: 153, height: 55, alignment: .leading)
                .padding(.bottom, 3)
                .foregroundColor(Color("DesignLightMutedOrange"))
            )
    }
    func DetailButtonisOffActive() -> some View {
        self
            .frame(width: 150, height: 52, alignment: .leading)
            .background(
                Color("DesignLightYellow")
            )
            .roundedCorner(12, corners: .topRight)
            .border(.bottom, width: 3, color: Color("DesignLightMutedOrange"))
            .background(
                Rectangle()
                .roundedCorner(12, corners: .topRight)
                .frame(width: 153, height: 55, alignment: .leading)
                .padding(.bottom, 3)
                .foregroundColor(Color("DesignLightMutedOrange"))
            )
    }
    
    func QAButtonisOnActive() -> some View {
        self
            .frame(width: 150, height: 52, alignment: .leading)
            .background(
                LinearGradient(colors: [Color("#FFFAE6"), Color("DesignLightYellow")],
                               startPoint: .top,
                               endPoint: .bottom)
            )
            .roundedCorner(12, corners: [.topRight, .topLeft])
            //.padding(.leading, -1.5)
            .background(
                Rectangle()
                    .roundedCorner(12, corners: [.topRight, .topLeft])
                .frame(width: 156, height: 55, alignment: .leading)
                .padding(.bottom, 3)
                .foregroundColor(Color("DesignLightMutedOrange"))
            )
    }
    
    func QAButtonisOffActive() -> some View {
        self
            .frame(width: 150, height: 52, alignment: .leading)
            .background(
                Color("DesignLightYellow")
            )
            .roundedCorner(12, corners: [.topRight, .topLeft])
            .background(
                Rectangle()
                    .roundedCorner(12, corners: [.topRight, .topLeft])
                .frame(width: 156, height: 55, alignment: .leading)
                .padding(.bottom, 3)
                .foregroundColor(Color("DesignLightMutedOrange"))
            )
    }
}

Solution

  • You are creating the border line by showing a shape in the background which is just slightly larger than the shape in the foreground. This is why the corners are not quite following the same contour.

    I would suggest, a better way to show the border is to use .stroke to draw the line around a shape instead.

    Currently, your styling is duplicated in a few different places. This could be avoided by putting all the styling into a single ButtonStyle. This can include the styling of the foreground text as well as the styling of the background and border.

    Here is an example ButtonStyle implementation, based on the code you provided:

    struct TabButton: ButtonStyle {
        let isOn: Bool
        let withLeftBorder: Bool
        let buttonWidth: CGFloat = 153
        let lineWidth: CGFloat = 2
    
        func makeBody(configuration: Configuration) -> some View {
            configuration.label
                .font(.title3)
                .fontWeight(.bold)
                .foregroundStyle(.designMiddleMutedOrange)
                .multilineTextAlignment(.center)
                .frame(width: buttonWidth, height: 55)
                .background {
                    if isOn {
                        Rectangle()
                            .fill(
                                LinearGradient(
                                    colors: [.FFFAE_6, .designLightYellow],
                                    startPoint: .top,
                                    endPoint: .bottom
                                )
                            )
                            .padding(.bottom, lineWidth)
                    } else {
                        Color.designLightYellow
                    }
                }
                .clipShape(
                    UnevenRoundedRectangle(
                        cornerRadii: .init(
                            topLeading: withLeftBorder ? 12 : 0,
                            topTrailing: 12
                        )
                    )
                )
                .overlay(alignment: .trailing) {
                    UnevenRoundedRectangle(
                        cornerRadii: .init(
                            topLeading: withLeftBorder ? 12 : 0,
                            topTrailing: 12
                        )
                    )
                    .stroke(.designMiddleMutedOrange, lineWidth: lineWidth)
                    .padding(.bottom, lineWidth / 2)
                    .frame(width: buttonWidth + (withLeftBorder ? 0 : lineWidth / 2))
                }
                .overlay(alignment: .bottom) {
                    if isOn {
                        Color.designLightYellow
                            .padding(.leading, withLeftBorder ? lineWidth / 2 : 0)
                            .padding(.trailing, lineWidth / 2)
                            .frame(height: lineWidth)
                    }
                }
        }
    }
    

    It works like this:

    • The text label is shown in the foreground.
    • The background is filled with the appropriate FillStyle, depending on whether the button is on or not.
    • The background is then clipped using an UnevenRoundedRectangle, rounding only the corners that need to be rounded.
    • Another UnevenRoundedRectangle is used for stroking the border as an overlay. Note that when a stroke is performed in this way, the stroke line is half-in and half-out (the middle of the line is on the frame edge), so padding is used to keep it inside the bottom edge.
    • If the left border is not supposed to be seen then the stroke shape is made slightly wider so that the left border is pushed off-screen. The overlay uses alignment: .trailing to ensure that the right border does not move.
    • If the button is on, the bottom edge of the border is masked with an overlay in the background color.

    BTW, I think your color "#FFFAE6" is misnamed because I think it must actually be more like #fec890.

    So here is how the button style can be used for your two buttons:

    struct ContentView: View {
        @State private var selectedTab = 0
    
        var body: some View {
            VStack(spacing: 0) {
                HStack(spacing: 0) {
                    Button("行程簡介") { selectedTab = 0 }
                        .buttonStyle(
                            TabButton(
                                isOn: selectedTab == 0,
                                withLeftBorder: false
                            )
                        )
                    Button("Q&A") { selectedTab = 1 }
                        .buttonStyle(
                            TabButton(
                                isOn: selectedTab == 1,
                                withLeftBorder: true
                            )
                        )
                    Spacer()
                }
                .background(alignment: .bottom) {
                    Color.designMiddleMutedOrange
                        .frame(height: 2) // = lineWidth of button border
                }
    
                Color.designLightYellow
            }
            .frame(height: 300)
        }
    }
    

    Animation


    EDIT Following up on your comment, UnevenRoundedRectangle was added in iOS 16.0. If it is not available to you then an alternative approach would be to use a custom Shape instead.

    Here is a custom shape that is basically just a rectangle with rounded top corners. The top-left corner is only rounded if flagged as required:

    struct TabButtonShape: Shape {
        let withRoundedTopLeftCorner: Bool
        let cornerRadius: CGFloat = 12
        func path(in rect: CGRect) -> Path {
            var path = Path()
            var x = rect.minX
            var y = rect.maxY
            path.move(to: CGPoint(x: x, y: y))
            y = rect.minY + (withRoundedTopLeftCorner ? cornerRadius : 0)
            path.addLine(to: CGPoint(x: x, y: y))
            if withRoundedTopLeftCorner {
                path.addArc(
                    center: CGPoint(x: x + cornerRadius, y: y),
                    radius: cornerRadius,
                    startAngle: .degrees(180),
                    endAngle: .degrees(270),
                    clockwise: false
                )
                y = rect.minY
            }
            x = rect.maxX - cornerRadius
            path.addLine(to: CGPoint(x: x, y: y))
            path.addArc(
                center: CGPoint(x: x, y: y + cornerRadius),
                radius: cornerRadius,
                startAngle: .degrees(270),
                endAngle: .degrees(0),
                clockwise: false
            )
            x = rect.maxX
            y = rect.maxY
            path.addLine(to: CGPoint(x: x, y: y))
            path.closeSubpath()
            return path
        }
    }
    

    This can be used to replace the UnevenRoundedRectangle that was being used before:

    .clipShape(
        TabButtonShape(withRoundedTopLeftCorner: withLeftBorder)
    //    UnevenRoundedRectangle(
    //        cornerRadii: .init(
    //            topLeading: withLeftBorder ? 12 : 0,
    //            topTrailing: 12
    //        )
    //    )
    )
    .overlay(alignment: .trailing) {
        TabButtonShape(withRoundedTopLeftCorner: withLeftBorder)
    //    UnevenRoundedRectangle(
    //        cornerRadii: .init(
    //            topLeading: withLeftBorder ? 12 : 0,
    //            topTrailing: 12
    //        )
    //    )
        .stroke(.designMiddleMutedOrange, lineWidth: lineWidth)
        .padding(.bottom, lineWidth / 2)
        .frame(width: buttonWidth + (withLeftBorder ? 0 : lineWidth / 2))
    }
    

    Hopefully, that gets the solution working for you now.