I'm trying to make the button look like this.
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:
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"))
)
}
}
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:
FillStyle
, depending on whether the button is on or not.UnevenRoundedRectangle
, rounding only the corners that need to be rounded.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.alignment: .trailing
to ensure that the right border does not move.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)
}
}
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.