SwiftUI: How to pass a View as a parameter into another View to get Bottom Sheet? - ios

I'm trying to create a View for the bottom sheet. It will have some view with a button when we tap on the button a bottom sheet has to appear.
I can modify the bottom sheet's colour, height, corner radius, how much the background view has to blur.
struct BottomSheetView: View {
#Binding var showBottomSheet: Bool
#Binding var bgColor: Color
#Binding var cornerRadius: CGFloat
#Binding var bottomSheetRatio: CGFloat
#Binding var blurPoint: CGFloat
var body: some View {
GeometryReader { geometry in
ZStack {
VStack {
Button(action: {
self.showBottomSheet.toggle()
}){
Text("click here")
.padding()
}
Spacer()
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.blur(radius: self.showBottomSheet ? self.blurPoint : 0)
.onTapGesture {
if self.showBottomSheet {
self.showBottomSheet = false
}
}
Rectangle()
.fill(self.bgColor)
.cornerRadius(self.cornerRadius)
.offset(y: self.showBottomSheet ? geometry.size.height * self.bottomSheetRatio : geometry.size.height + 200)
.animation(.spring())
}
}
}
}
In the above code
VStack {
Button(action: {
self.showBottomSheet.toggle()
}){
Text("click here")
.padding()
}
Spacer()
}
This VStack should replace with some other View, from that View I want to pass the Binding values.
Is there a way to achieve that? Thanks in advance.

I have achieved the requirement with ViewModifier
First I have created a ViewModifier struct. which will have all the required values in init()
struct BottomSheetModifier: ViewModifier {
var showBottomSheet: Bool
var blurPoint: CGFloat
var bgColor: Color
var cornerRadius: CGFloat
var bottomSheetRatio: CGFloat
init(showBottomSheet: Bool, blurPoint: CGFloat, bgColor: Color, cornerRadius: CGFloat, bottomSheetRatio: CGFloat) {
self.showBottomSheet = showBottomSheet
self.blurPoint = blurPoint
self.bgColor = bgColor
self.cornerRadius = cornerRadius
self.bottomSheetRatio = bottomSheetRatio
}
func body(content: Content) -> some View {
GeometryReader { geometry in
ZStack {
content
.blur(radius: self.showBottomSheet ? self.blurPoint : 0)
RoundedRectangle(cornerRadius: self.cornerRadius)
.fill(self.bgColor)
.offset(y: self.showBottomSheet ? geometry.size.height * self.bottomSheetRatio : geometry.size.height + 200)
.animation(.spring())
}
}
}
}
Second I've created an extension for View which will have a function with all the values as arguments and initialized the modifier in it.
extension View {
func bottomSheet(showBottomSheet: Bool, blurPoint: CGFloat, bgColor: Color, cornerRadius: CGFloat, bottomSheetRatio: CGFloat) -> some View {
modifier(BottomSheetModifier(showBottomSheet: showBottomSheet, blurPoint: blurPoint, bgColor: bgColor, cornerRadius: cornerRadius, bottomSheetRatio: bottomSheetRatio))
}
}
Third I've created a View. In that I have added some elements and simply call the modifier here with my required bottomsheet size, colour and other values.
struct DemoBottomSheetView: View {
#Binding var showBottomSheet: Bool
var body: some View {
VStack(spacing: 20) {
Text("welcome to bottom sheet")
Button(action: {
self.showBottomSheet.toggle()
}){
Text("Click Me")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
Spacer()
}
.onTapGesture {
if self.showBottomSheet {
self.showBottomSheet = false
}
}
.bottomSheet(showBottomSheet: self.showBottomSheet, blurPoint: 4, bgColor: .red, cornerRadius: 25, bottomSheetRatio: 0.5)
}
}
Final output

struct BottomSheetView<Content: View>: View {
var content: Content
...
var body: some View {
content //do anything you want with content
}
}
and you need to pass your content in your View Ininit
like
BottomSheetView(content: Text("test"),
showBottomSheet: $test1,
bgColor: $test2,
cornerRadius: $test3,
bottomSheetRatio: $ctest4,
blurPoint: $test5)

Related

Custom picker switching views in SwiftUI

I am developing an app with a custom dock and I am unsure of how to change the view when highlighted on an item in the dock. I just couldn't find a way to switch the view when you highlight the item. I have attempted methods such as a switch statement but that did not work in my scenario. I have also attempted to use an if-else statement but that also did not work. I would much appreciate your help in finding a solution to this issue. Please review my code below...
struct MathematicallyController: View {
#State var selection: Int = 1
var body: some View {
ZStack {
ZStack {
if selection == 0 {
//view 1
} else if selection == 1 {
//view 2
} else if selection == 2 {
//view 3
} else {
//view 1
}
}
.overlay(
VStack {
Spacer()
ZStack {
BlurView(style: .systemThinMaterialDark)
.frame(maxWidth: .infinity, maxHeight: 65)
.cornerRadius(20)
.padding()
RoundedRectangle(cornerRadius: 20)
.stroke(lineWidth: 0.9)
.frame(maxWidth: .infinity, maxHeight: 65)
.blur(radius: 2)
.padding()
Picker(selection: selection)
.padding(5)
}
.offset(y: 30)
}
)
}
}
}
Picker
extension View {
func eraseToAnyView() -> AnyView {
AnyView(self)
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct BackgroundGeometryReader: View {
var body: some View {
GeometryReader { geometry in
return Color
.clear
.preference(key: SizePreferenceKey.self, value: geometry.size)
}
}
}
struct SizeAwareViewModifier: ViewModifier {
#Binding private var viewSize: CGSize
init(viewSize: Binding<CGSize>) {
self._viewSize = viewSize
}
func body(content: Content) -> some View {
content
.background(BackgroundGeometryReader())
.onPreferenceChange(SizePreferenceKey.self, perform: { if self.viewSize != $0 { self.viewSize = $0 }})
}
}
struct SegmentedPicker: View {
private static let ActiveSegmentColor: Color = Color(.tertiarySystemBackground)
private static let BackgroundColor: Color = Color(.secondarySystemBackground)
private static let ShadowColor: Color = Color.white.opacity(0.2)
private static let TextColor: Color = Color(.secondaryLabel)
private static let SelectedTextColor: Color = Color(.label)
private static let TextFont: Font = .system(size: 12)
private static let SegmentCornerRadius: CGFloat = 12
private static let ShadowRadius: CGFloat = 10
private static let SegmentXPadding: CGFloat = 16
private static let SegmentYPadding: CGFloat = 9
private static let PickerPadding: CGFloat = 7
private static let AnimationDuration: Double = 0.2
// Stores the size of a segment, used to create the active segment rect
#State private var segmentSize: CGSize = .zero
// Rounded rectangle to denote active segment
private var activeSegmentView: AnyView {
// Don't show the active segment until we have initialized the view
// This is required for `.animation()` to display properly, otherwise the animation will fire on init
let isInitialized: Bool = segmentSize != .zero
if !isInitialized { return EmptyView().eraseToAnyView() }
return
RoundedRectangle(cornerRadius: SegmentedPicker.SegmentCornerRadius)
.fill(.regularMaterial)
.shadow(color: SegmentedPicker.ShadowColor, radius: SegmentedPicker.ShadowRadius)
.frame(width: self.segmentSize.width, height: self.segmentSize.height)
.offset(x: self.computeActiveSegmentHorizontalOffset(), y: 0)
.animation(Animation.linear(duration: SegmentedPicker.AnimationDuration))
.overlay(
RoundedRectangle(cornerRadius: SegmentedPicker.SegmentCornerRadius)
.stroke(lineWidth: 1)
.shadow(color: SegmentedPicker.ShadowColor, radius: SegmentedPicker.ShadowRadius)
.frame(width: self.segmentSize.width, height: self.segmentSize.height)
.offset(x: self.computeActiveSegmentHorizontalOffset(), y: 0)
.animation(Animation.linear(duration: SegmentedPicker.AnimationDuration))
)
.eraseToAnyView()
}
#Binding private var selection: Int
private let items: [Image]
init(items: [Image], selection: Binding<Int>) {
self._selection = selection
self.items = items
}
var body: some View {
// Align the ZStack to the leading edge to make calculating offset on activeSegmentView easier
ZStack(alignment: .leading) {
// activeSegmentView indicates the current selection
self.activeSegmentView
HStack {
ForEach(0..<self.items.count, id: \.self) { index in
self.getSegmentView(for: index)
}
}
}
.padding(SegmentedPicker.PickerPadding)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: SegmentedPicker.SegmentCornerRadius))
}
// Helper method to compute the offset based on the selected index
private func computeActiveSegmentHorizontalOffset() -> CGFloat {
CGFloat(self.selection) * (self.segmentSize.width + SegmentedPicker.SegmentXPadding / 2)
}
// Gets text view for the segment
private func getSegmentView(for index: Int) -> some View {
guard index < self.items.count else {
return EmptyView().eraseToAnyView()
}
let isSelected = self.selection == index
return
Text(self.items[index])
// Dark test for selected segment
.foregroundColor(isSelected ? SegmentedPicker.SelectedTextColor: SegmentedPicker.TextColor)
.lineLimit(1)
.padding(.vertical, SegmentedPicker.SegmentYPadding)
.padding(.horizontal, SegmentedPicker.SegmentXPadding)
.frame(minWidth: 0, maxWidth: .infinity)
// Watch for the size of the
.modifier(SizeAwareViewModifier(viewSize: self.$segmentSize))
.onTapGesture { self.onItemTap(index: index) }
.eraseToAnyView()
}
// On tap to change the selection
private func onItemTap(index: Int) {
guard index < self.items.count else {
return
}
self.selection = index
}
}
struct Picker: View {
#State var selection: Int = 1
private let items: [Image] = [Image(systemName: "rectangle.on.rectangle"), Image(systemName: "timelapse"), Image(systemName: "plus")]
var body: some View {
SegmentedPicker(items: self.items, selection: self.$selection)
.padding()
}
}
In your Picker struct you are getting selection as a value not a Binding.
The purpose of using a Binding variable is to make the parent of the passed variable listen to the changes made in the struct. In other words, it binds the 2 views/values.
So what you should do is modify Picker like this:
struct Picker: View {
#Binding var selection: Int = 1
private let items: [Image] = [Image(systemName: "rectangle.on.rectangle"), Image(systemName: "timelapse"), Image(systemName: "plus")]
var body: some View {
SegmentedPicker(items: self.items, selection: self.$selection)
.padding()
}
}
And in MathematicallyController change Picker(selection: selection) into Picker(selection: $selection) so you'd have:
struct MathematicallyController: View {
#State var selection: Int = 1
var body: some View {
ZStack {
ZStack {
if selection == 0 {
//view 1
} else if selection == 1 {
//view 2
} else if selection == 2 {
//view 3
} else {
//view 1
}
}
.overlay(
VStack {
Spacer()
ZStack {
BlurView(style: .systemThinMaterialDark)
.frame(maxWidth: .infinity, maxHeight: 65)
.cornerRadius(20)
.padding()
RoundedRectangle(cornerRadius: 20)
.stroke(lineWidth: 0.9)
.frame(maxWidth: .infinity, maxHeight: 65)
.blur(radius: 2)
.padding()
Picker(selection: $selection)
.padding(5)
}
.offset(y: 30)
}
)
}
}
}
Also Note that a switch statement will work just as fine as an if one.

Animate ViewBuilder content size change in SwiftUI

I am wondering how I can animate the content size of a ViewBuilder view. I have this:
struct CardView<Content>: View where Content: View {
private let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
VStack(spacing: 0) {
content
.padding(16)
}
.background(.white)
.cornerRadius(14)
.shadow(color: .black.opacity(0.07), radius: 12, x: 0, y: 2)
}
}
I would like to animate any size changes to content, but I can't find a nice way of doing this. I found two ways that work:
Using animation(.linear) in CardView works, but is deprecated and discouraged since I have no value to attach the animation to.
Using withAnimation inside content when changing the content works, too, but I would like to encapsulate this behaviour in CardView. CardView is heavily reused and doing it in content is easy to forget and also not where this behaviour belongs in my opinion.
I also tried using GeometryReader but could not find a good way of doing it.
Here is an approach for you:
You may take look at this link as well:
How to replace deprecated .animation() in SwiftUI?
struct ContentView: View {
#State private var cardSize: CGSize = CGSize(width: 150, height: 200)
var body: some View {
VStack {
CardView(content: {
Color.red
.overlay(Image(systemName: "dollarsign.circle").resizable().scaledToFit().padding())
.onTapGesture {
cardSize = CGSize(width: cardSize.width + 50, height: cardSize.height + 50)
}
}, cardSize: cardSize)
}
}
}
struct CardView<Content>: View where Content: View {
let content: Content
let cardSize: CGSize
init(#ViewBuilder content: () -> Content, cardSize: CGSize) {
self.content = content()
self.cardSize = cardSize
}
var body: some View {
content
.frame(width: cardSize.width, height: cardSize.height)
.cornerRadius(14)
.padding(16)
.background(.white)
.shadow(color: .black.opacity(0.07), radius: 12, x: 0, y: 2)
.animation(.easeInOut, value: cardSize)
}
}
You might find this useful.
It uses a looping animation and a user gesture for add size and resting.
struct PilotTestPage: View {
#State private var cardSize = CGSize(width: 150, height: 200)
var body: some View {
return ZStack {
Color.yellow
CardView() {
Color.clear
.overlay(
Image(systemName: "dollarsign.circle")
.resizable()
.scaledToFit()
.padding()
)
}
.frame(
width: cardSize.width
,height: cardSize.height
)
.onTapGesture {
withAnimation {
cardSize = CGSize(
width: cardSize.width + 50
,height: cardSize.height + 50
)
}
}
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.red)
.frame(
width: 200
,height: 44
)
.offset(y: -300)
.onTapGesture {
withAnimation {
cardSize = CGSize(
width: 150
,height: 200
)
}
}
}
.ignoresSafeArea()
}
struct CardView<Content>: View where Content: View {
let content: Content
init(
#ViewBuilder content: () -> Content
) {
self.content = content()
}
#State private var isAtStart = true
var body: some View {
ZStack {
content
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.white)
.shadow(
color: .black.opacity(0.25)
,radius: 12
,x: 0
,y: 2
)
)
}
.scaleEffect(isAtStart ? 0.9 : 1.0)
.rotationEffect(.degrees(isAtStart ? -2 : 2))
.onAppear {
withAnimation(
.easeInOut(duration: 1)
.repeatForever()
) {
self.isAtStart.toggle()
}
}
}
}
}

Is there a way to increase the tappable area of a Picker in SwiftUI?

I have created a custom picker that is larger than the native SwiftUI picker. This picker is being used on an iPad which is why I need it larger than usual. When I use the picker, I can't tap on the padding portions. The picker is only opened when I tap directly in the horizontal center of my picker. I have read about using a .frame() modifier to change the tappable area of things like buttons, but that does not seem to work here when I try to add a frame modifier to the base Picker itself. Here is an image of the additional area (in orange) I would like to make tappable
And here is my code:
import SwiftUI
struct CustomPickerStyle: ViewModifier {
var labelText: String
var width: CGFloat
func body(content: Content) -> some View {
Menu {
content
} label: {
HStack {
if let labelText = labelText {
Text(labelText)
.font(.title2)
.fontWeight(.bold)
Spacer()
Image(systemName: "triangle.fill")
.resizable()
.frame(width: 12, height: 8)
.rotationEffect(.degrees(180))
}
}
}
.frame(maxWidth: width, alignment: .leading)
.padding()
.background(.white)
.overlay(
RoundedRectangle(cornerRadius: 3)
.stroke(.gray, lineWidth: 2)
)
}
}
extension View {
func customPickerStyle(labelText: String, width: CGFloat) -> some View {
self.modifier(CustomPickerStyle(labelText: labelText, width: width))
}
}
struct CustomPicker: View {
enum Flavor: String, CaseIterable, Identifiable {
case chocolate, vanilla, strawberry
var id: Self { self }
}
#State private var selectedFlavor: Flavor = .chocolate
var body: some View {
Picker("Flavor", selection: $selectedFlavor) {
Text("Chocolate").tag(Flavor.chocolate)
Text("Vanilla").tag(Flavor.vanilla)
Text("Strawberry").tag(Flavor.strawberry)
}
.customPickerStyle(labelText: selectedFlavor.rawValue, width: 200)
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
CustomPicker()
}
}
Just move your padding and background stylings directly inside the label:
struct CustomPickerStyle: ViewModifier {
var labelText: String
var width: CGFloat
func body(content: Content) -> some View {
Menu {
content
} label: {
HStack {
if let labelText = labelText {
Text(labelText)
.font(.title2)
.fontWeight(.bold)
Spacer()
Image(systemName: "triangle.fill")
.resizable()
.frame(width: 12, height: 8)
.rotationEffect(.degrees(180))
}
}
.frame(maxWidth: width, alignment: .leading)
.padding()
.background(.white)
.overlay(
RoundedRectangle(cornerRadius: 3)
.stroke(.gray, lineWidth: 2)
)
}
}
}

How to tease adjacent pages in PageTabView?

I am working with SwiftUI 2 and using a TabView with PageTabViewStyle.
Now, I am searching for a way to "tease" the pages adjacent to the current page like so:
Is it possible to achieve this effect with TabView and PageTabViewStyle?
I already tried to reduce the width of my TabView to be windowWidth-50. However, this did not lead to the adjacent pages being visible at the sides. Instead, this change introduced a hard vertical edge 50px left of the right window border, where new pages would slide in.
Here is a simple implementation. You can use the struct with the AnyView array or use the logic directly in your own implementation.
struct ContentView: View {
#State private var selected = 4
var body: some View {
// the trailing closure takes an Array of AnyView type erased views
TeasingTabView(selectedTab: $selected, spacing: 20) {
[
AnyView(TabContentView(title: "First", color: .yellow)),
AnyView(TabContentView(title: "Second", color: .orange)),
AnyView(TabContentView(title: "Fourth", color: .green)),
AnyView(TabContentView(title: "Fifth", color: .blue)),
AnyView(
Image(systemName: "lizard")
.resizable().scaledToFit()
.padding()
.frame(maxHeight: .infinity)
.border(.red)
)
]
}
}
}
struct TeasingTabView: View {
#Binding var selectedTab: Int
let spacing: CGFloat
let views: () -> [AnyView]
#State private var offset = CGFloat.zero
var viewCount: Int { views().count }
var body: some View {
VStack(spacing: spacing) {
GeometryReader { geo in
let width = geo.size.width * 0.7
LazyHStack(spacing: spacing) {
Color.clear
.frame(width: geo.size.width * 0.15 - spacing)
ForEach(0..<viewCount, id: \.self) { idx in
views()[idx]
.frame(width: width)
.padding(.vertical)
}
}
.offset(x: CGFloat(-selectedTab) * (width + spacing) + offset)
.animation(.easeOut, value: selectedTab)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation.width
}
.onEnded { value in
withAnimation(.easeOut) {
offset = value.predictedEndTranslation.width
selectedTab -= Int((offset / width).rounded())
selectedTab = max(0, min(selectedTab, viewCount-1))
offset = 0
}
}
)
}
//
HStack {
ForEach(0..<viewCount, id: \.self) { idx in
Circle().frame(width: 8)
.foregroundColor(idx == selectedTab ? .primary : .secondary.opacity(0.5))
.onTapGesture {
selectedTab = idx
}
}
}
}
}
}
struct TabContentView: View {
let title: String
let color: Color
var body: some View {
Text(title).font(.title)
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(color.opacity(0.4), ignoresSafeAreaEdges: .all)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
}

SwiftUI Create a Custom Segmented Control also in a ScrollView

Below is my code to create a standard segmented control.
struct ContentView: View {
#State private var favoriteColor = 0
var colors = ["Red", "Green", "Blue"]
var body: some View {
VStack {
Picker(selection: $favoriteColor, label: Text("What is your favorite color?")) {
ForEach(0..<colors.count) { index in
Text(self.colors[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
Text("Value: \(colors[favoriteColor])")
}
}
}
My question is how could I modify it to have a customized segmented control where I can have the boarder rounded along with my own colors, as it was somewhat easy to do with UIKit? Has any one done this yet.
I prefect example is the Uber eats app, when you select a restaurant you can scroll to the particular portion of the menu by selecting an option in the customized segmented control.
Included are the elements I'm looking to have customized:
* UPDATE *
Image of the final design
Is this what you are looking for?
import SwiftUI
struct CustomSegmentedPickerView: View {
#State private var selectedIndex = 0
private var titles = ["Round Trip", "One Way", "Multi-City"]
private var colors = [Color.red, Color.green, Color.blue]
#State private var frames = Array<CGRect>(repeating: .zero, count: 3)
var body: some View {
VStack {
ZStack {
HStack(spacing: 10) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: { self.selectedIndex = index }) {
Text(self.titles[index])
}.padding(EdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20)).background(
GeometryReader { geo in
Color.clear.onAppear { self.setFrame(index: index, frame: geo.frame(in: .global)) }
}
)
}
}
.background(
Capsule().fill(
self.colors[self.selectedIndex].opacity(0.4))
.frame(width: self.frames[self.selectedIndex].width,
height: self.frames[self.selectedIndex].height, alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX)
, alignment: .leading
)
}
.animation(.default)
.background(Capsule().stroke(Color.gray, lineWidth: 3))
Picker(selection: self.$selectedIndex, label: Text("What is your favorite color?")) {
ForEach(0..<self.titles.count) { index in
Text(self.titles[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
Text("Value: \(self.titles[self.selectedIndex])")
Spacer()
}
}
func setFrame(index: Int, frame: CGRect) {
self.frames[index] = frame
}
}
struct CustomSegmentedPickerView_Previews: PreviewProvider {
static var previews: some View {
CustomSegmentedPickerView()
}
}
If I'm following the question aright the starting point might be something like the code below. The styling, clearly, needs a bit of attention. This has a hard-wired width for segments. To be more flexible you'd need to use a Geometry Reader to measure what was available and divide up the space.
struct ContentView: View {
#State var selection = 0
var body: some View {
let item1 = SegmentItem(title: "Some Way", color: Color.blue, selectionIndex: 0)
let item2 = SegmentItem(title: "Round Zip", color: Color.red, selectionIndex: 1)
let item3 = SegmentItem(title: "Multi-City", color: Color.green, selectionIndex: 2)
return VStack() {
Spacer()
Text("Selected Item: \(selection)")
SegmentControl(selection: $selection, items: [item1, item2, item3])
Spacer()
}
}
}
struct SegmentControl : View {
#Binding var selection : Int
var items : [SegmentItem]
var body : some View {
let width : CGFloat = 110.0
return HStack(spacing: 5) {
ForEach (items, id: \.self) { item in
SegmentButton(text: item.title, width: width, color: item.color, selectionIndex: item.selectionIndex, selection: self.$selection)
}
}.font(.body)
.padding(5)
.background(Color.gray)
.cornerRadius(10.0)
}
}
struct SegmentButton : View {
var text : String
var width : CGFloat
var color : Color
var selectionIndex = 0
#Binding var selection : Int
var body : some View {
let label = Text(text)
.padding(5)
.frame(width: width)
.background(color).opacity(selection == selectionIndex ? 1.0 : 0.5)
.cornerRadius(10.0)
.foregroundColor(Color.white)
.font(Font.body.weight(selection == selectionIndex ? .bold : .regular))
return Button(action: { self.selection = self.selectionIndex }) { label }
}
}
struct SegmentItem : Hashable {
var title : String = ""
var color : Color = Color.white
var selectionIndex = 0
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
None of the above solutions worked for me as the GeometryReader returns different values once placed in a Navigation View that throws off the positioning of the active indicator in the background. I found alternate solutions, but they only worked with fixed length menu strings. Perhaps there is a simple modification to make the above code contributions work, and if so, I would be eager to read it. If you're having the same issues I was, then this may work for you instead.
Thanks to inspiration from a Reddit user "End3r117" and this SwiftWithMajid article, https://swiftwithmajid.com/2020/01/15/the-magic-of-view-preferences-in-swiftui/, I was able to craft a solution. This works either inside or outside of a NavigationView and accepts menu items of various lengths.
struct SegmentMenuPicker: View {
var titles: [String]
var color: Color
#State private var selectedIndex = 0
#State private var frames = Array<CGRect>(repeating: .zero, count: 5)
var body: some View {
VStack {
ZStack {
HStack(spacing: 10) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: {
print("button\(index) pressed")
self.selectedIndex = index
}) {
Text(self.titles[index])
.foregroundColor(color)
.font(.footnote)
.fontWeight(.semibold)
}
.padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5))
.modifier(FrameModifier())
.onPreferenceChange(FramePreferenceKey.self) { self.frames[index] = $0 }
}
}
.background(
Rectangle()
.fill(self.color.opacity(0.4))
.frame(
width: self.frames[self.selectedIndex].width,
height: 2,
alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX, y: self.frames[self.selectedIndex].height)
, alignment: .leading
)
}
.padding(.bottom, 15)
.animation(.easeIn(duration: 0.2))
Text("Value: \(self.titles[self.selectedIndex])")
Spacer()
}
}
}
struct FramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct FrameModifier: ViewModifier {
private var sizeView: some View {
GeometryReader { geometry in
Color.clear.preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global))
}
}
func body(content: Content) -> some View {
content.background(sizeView)
}
}
struct NewPicker_Previews: PreviewProvider {
static var previews: some View {
VStack {
SegmentMenuPicker(titles: ["SuperLongValue", "1", "2", "Medium", "AnotherSuper"], color: Color.blue)
NavigationView {
SegmentMenuPicker(titles: ["SuperLongValue", "1", "2", "Medium", "AnotherSuper"], color: Color.red)
}
}
}
}

Resources