Boring start:
You need to create a segmented control. The effect is as follows:
Does it look familiar? Yes, it looks like Android, right?! The black underline will move under the selected title, and can automatically expand and shrink according to the length of the text.
Why is it so Android like? Because I spent an afternoon getting it out and found that the Figma design page I opened was Android...
So well, if you pee on your hands, no, if you need a similar design, I hope this article can help you.
Lace text:
Don't ask me why the text is lace, don't you like it?!
First, SwiftUI's own Picker # can create the following Segmented control
Well, it's estimated that no designer will agree to use this, so custom layout is inevitable. But the segmented pickerstyle can change too few things. So let's build it ourselves.
Step 1: create a demo project and select SwiftUI App. For the convenience of demonstration, if you are used to AppDelegate, it is the same.
After the creation, you get a display of "Hello, world!" Empty item, experience value + 1...
Step 2: create a new SwiftUI class called Xiaojuan or SegmentedControl. Replace with the following code
import SwiftUI struct SegmentedControl: View { @Binding private var selection: Int @State private var segmentSize: CGSize = .zero private let items: [String] private let xSpace: CGFloat = 4 public init(items: [String], selection: Binding<Int>) { self._selection = selection self.items = items } var body: some View { // 1 VStack(alignment: .center, spacing: 0) { // 2 VStack(alignment: .leading, spacing: 0) { // 3 HStack(spacing: xSpace) { ForEach(0 ..< items.count, id: \.self) { index in segmentItemView(for: index) } } .padding(.bottom, 12) } .padding(.horizontal, xSpace) // full width divider Divider() } } // 4 } struct SegmentedControl_Previews: PreviewProvider { static var previews: some View { SegmentedControl(items: ["S", "Hanmeimei", "Lilie"], selection: .constant(0)) } }
In the comments of the above code:
/ / 1, / / 2, / / 3 three stacks build the basic structure. The / / 3 - hsstack contains all the items in the Segmented control The index here will be below 👇 need.
Step 3: prepare for takeoff
The above code is missing the segmentItemView(:) function. Here:
private func segmentItemView(for index: Int) -> some View { guard index < self.items.count else { // 5 return EmptyView().eraseToAnyView() } let isSelected = self.selection == index return Text(items[index]) .font(.caption) .foregroundColor(isSelected ? .blue : .gray) // 6 .background(BackgroundGeometryReader()) // 7 .onPreferenceChange(SizePreferenceKey.self) { // 8 itemTitleSizes[index] = $0 } .onTapGesture { onItemTap(index: index) } .eraseToAnyView() } private func onItemTap(index: Int) { guard index < self.items.count else { return } self.selection = index }
Copy to the location of code / / 4 above
Here is a brief explanation of the code:
//5, / / 6, / / 7 use three auxiliary functions. Create a new swift class, helper Swift, copy the following code:
import SwiftUI / Helpers / extension View { func eraseToAnyView() -> AnyView { AnyView(self) } } /// PreferenceKey for a subview to notify superview of its size struct SizePreferenceKey: PreferenceKey { public typealias Value = CGSize public static var defaultValue: Value = .zero public static func reduce(value: inout Value, nextValue: () -> Value) { value = nextValue() } } struct BackgroundGeometryReader: View { public init() {} public var body: some View { GeometryReader { geometry in return Color .clear .preference(key: SizePreferenceKey.self, value: geometry.size) } } } / End of Helpers /
The , SizePreferenceKey and , BackgroundGeometryReader here are used to pass the size of the subview to the parent view.
Returning to the previous code segment, / / 8 uses a new array variable, / / itemTitleSizes, to store the text length of each segment. This variable is an array of @ State s, which can be assigned according to items in the init() function.
struct SegmentedControl: View { @Binding private var selection: Int @State private var segmentSize: CGSize = .zero // 9 @State private var itemTitleSizes: [CGSize] = [] private let items: [String] private let xSpace: CGFloat = 4 public init(items: [String], selection: Binding<Int>) { self._selection = selection self.items = items // 10 self._itemTitleSizes = State(initialValue: [CGSize](repeating: .zero, count: items.count)) }
Add two lines corresponding to / / 9 / / 10.
So far, segmentedcontrol The swift class should have no errors. Now we need to modify the contentview Swift code to test the UI.
The code to replace ContentView is as follows:
import SwiftUI struct ContentView: View { @State private var selectedIndex: Int = 0 private let demo = ["A", "Long name", "sn"] var body: some View { SegmentedControl(items: demo, selection: $selectedIndex) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
Compile and run, and the effect is as follows:
Well, nothing! Let's continue... TO BE CONTINUED. . .
Step 4: the BOSS is coming
Back to segmentedcontrol Swift, modify the code in the body as follows
var body: some View { // 1 VStack(alignment: .center, spacing: 0) { // 2 VStack(alignment: .leading, spacing: 0) { // 3 HStack(spacing: xSpace) { ForEach(0 ..< items.count, id: \.self) { index in segmentItemView(for: index) } } .padding(.bottom, 12) // 11 Rectangle() .foregroundColor(.black) // 12 .frame(width: selectedItemWidth, height: 3) // 13 .offset(x: selectedItemHorizontalOffset(), y: 0) .animation(Animation.linear(duration: 0.3)) } .padding(.horizontal, xSpace) // full width divider Divider() } }
//11. Rectangle() is the little BOSS of this article! Guess who the big BOSS is?
//The selectedItemWidth of 12 is actually the value corresponding to the selected index in itemTitleSizes, so
private var selectedItemWidth: CGFloat { return itemTitleSizes.count > selection ? itemTitleSizes[selection].width : .zero }
//At 13, you need to calculate the offset, which refers to the distance from the leftmost side of the SegmentedControl to the selected item. You need to calculate slightly:
private func selectedItemHorizontalOffset() -> CGFloat { guard selectedItemWidth != .zero, selection != 0 else { return 0 } let result = itemTitleSizes .enumerated() // 14 .filter { $0.offset < selection } // 15 .map { $0.element.width } // 16 .reduce(0, +) return result + xSpace * CGFloat(selection) }
Thanks to Swift, this code should be well understood:
//14 - filter finds all items whose index is less than the selection (the offset here is the offset after enumerated, which is the index of the array)
//15 - map "lists" the width values of all the items found
//16 - reduce(, +) to add all the width
return needs to add n {xSpace (the space between two item s).
Now there is another value we haven't assigned to him, which is segmentSize, We need to know the length of the whole segmented control (used for calculation). How to get the size of a View in SwiftUI? What we can think of right away is to use , GeometryReader, but I don't suggest you directly add GeometryReader to our VStack, because , GeometryReader starts from the upper left corner of the whole screen, which will have some unexpected consequences (although it will not have an impact in our demo).
Let's still use a color Clear to calculate the length of the view. Modify the body code as follows
var body: some View { ZStack { GeometryReader { geometry in Color .clear .onAppear { segmentSize = geometry.size } } .frame(maxWidth: .infinity, maxHeight: 1) VStack(alignment: .center, spacing: 0) { VStack(alignment: .leading, spacing: 0) { HStack(spacing: xSpace) { ForEach(0 ..< items.count, id: \.self) { index in segmentItemView(for: index) } } .padding(.bottom, 12) Rectangle() .foregroundColor(.black) .frame(width: selectedItemWidth, height: 3) .offset(x: selectedItemHorizontalOffset(), y: 0) .animation(Animation.linear(duration: 0.3)) } .padding(.horizontal, xSpace) // full width divider Divider() } } }
Add a ZStack and a clear Color for calculating size
Okay, if you run the program now, everything is ideal. And you will find that if you think the spacing between item s is not large enough, you can modify the value of xSpace to change it... Is it? How big will it be? Huh?
Final BOSS - xSpace
Go directly to the code and modify xSpace to
private var xSpace: CGFloat { guard !itemTitleSizes.isEmpty, !items.isEmpty, segmentSize.width != 0 else { return 0 } let itemWidthSum: CGFloat = itemTitleSizes.map { $0.width }.reduce(0, +).rounded() let space = (segmentSize.width - itemWidthSum) / CGFloat(items.count + 1) return max(space, 0) }
Just one thing - average spacing! (I guess you're going to curse your mother) but it took me more than 30 minutes to figure it out. If you have a better way, remember to leave a message!
All right, it's done! Now run, the effect is the one at the beginning of the article gif
Last last:
Demo repo: https://github.com/ylem/swiftui-segmented-control-demo