Custom picker switching views in SwiftUI - ios

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.

Related

How can I add a more rows of buttons in my frame when my hstack is too long in Swift UI?

I would like to make the second row appear when my list is too long.
Do you have any idea how to do that?
Thank you in advance!
import SwiftUI
struct ContentView: View {
#StateObject var vm = SpeakingVM()
var speakingModel: SpeakingModel
var body: some View {
HStack(spacing:20){
ForEach(speakingModel.sentence.indices) { index in
Button(action: {
}, label: {
Text(speakingModel.sentence[index].definition)
.padding(.vertical,10)
.padding(.horizontal)
.background(Capsule().stroke(Color.blue))
.lineLimit(1)
})
}
}.frame(width: UIScreen.main.bounds.width - 30, height: UIScreen.main.bounds.height / 3)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(speakingModel: SpeakingModel(sentence: [SpeakingModel.Word(definition: "Météo"),SpeakingModel.Word(definition: "Cheval"),SpeakingModel.Word(definition: "Ascenceur")], sentenceInFrench: "Quel temps fait-il ?"))
}
}
What i would like :
Put your data in tags and customize the item view and change it appropriately.
import SwiftUI
struct HashTagView: View {
#State var tags: [String] = ["#Lorem", "#Ipsum", "#dolor", "#consectetur", "#adipiscing", "#elit", "#Nam", "#semper", "#sit", "#amet", "#ut", "#eleifend", "#Cras"]
#State private var totalHeight = CGFloat.zero
var body: some View {
VStack {
GeometryReader { geometry in
self.generateContent(in: geometry)
}
}
.frame(height: totalHeight)
}
private func generateContent(in g: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(self.tags, id: \.self) { tag in
self.item(for: tag)
.padding([.horizontal, .vertical], 4)
.alignmentGuide(.leading, computeValue: { d in
if (abs(width - d.width) > g.size.width)
{
width = 0
height -= d.height
}
let result = width
if tag == self.tags.last! {
width = 0 //last item
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: {d in
let result = height
if tag == self.tags.last! {
height = 0 // last item
}
return result
})
}
}.background(viewHeightReader($totalHeight))
}
private func item(for text: String) -> some View {
Text(text)
.padding(.all, 5)
.font(.body)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
return GeometryReader { geometry -> Color in
let rect = geometry.frame(in: .local)
DispatchQueue.main.async {
binding.wrappedValue = rect.size.height
}
return .clear
}
}
}
struct HashTagView_Previews: PreviewProvider {
static var previews: some View {
HashTagView()
}
}

SwiftUI: Drawing a line between two views in a list / how to determine the center location of a view?

I want to draw a line between a selected source-View and a selected target-View when I press the "connect" button.
As far as I understand I need the (center?) CGPoint of these views...
How can I determine the center CGPoint of a view inside a forEach-HStack-list?
Elements from both lists will be removed/added.
struct ContentView: View {
#State private var selectedTarget: Int = 0
#State private var selectedSource: Int = 3
#State private var isConnected = false
var body: some View {
ZStack {
VStack {
HStack {
ForEach(0..<6) { index in
Text("Target \(index)")
.padding()
.foregroundColor(.white)
.background(selectedTarget == index ? .red : .black)
.onTapGesture {
selectedTarget = index
}
}
}
List {
EmptyView()
}
.frame(width: 400, height: 400, alignment: .center)
.border(.black, width: 2)
HStack {
ForEach(0..<6) { index in
Text("Source \(index)")
.padding()
.foregroundColor(.white)
.background(selectedSource == index ? .orange : .black)
.onTapGesture {
selectedSource = index
}
}
}
Button(isConnected ? "CUT" : "CONNECT") {
isConnected.toggle()
}
.padding()
}
}
}
}
Here is the rough demo code and take an idea from this code.
You can achieve this by first finding the position of the Text by GeometryReader and draw the path between those points.
struct ContentView: View {
#State private var selectedTarget: Int = 0
#State private var selectedSource: Int = 3
#State private var isConnected = false
#State private var targetCGPoint: CGPoint = .zero
#State private var sourceCGPoint: CGPoint = .zero
var body: some View {
ZStack {
if isConnected {
getPath()
}
VStack {
HStack {
ForEach(0..<6) { index in
GeometryReader { geo in
Text("Target \(index)")
.padding()
.foregroundColor(.white)
.background(selectedTarget == index ? Color.red : Color.black)
.onTapGesture {
let size = geo.size
targetCGPoint = CGPoint(x: geo.frame(in: .global).origin.x + (size.width / 2), y: geo.frame(in: .global).origin.y)
print("Target Global center: \(geo.frame(in: .global).midX) x \(geo.frame(in: .global).midY)")
selectedTarget = index
}
}
}
}
// Removed list for demo
Spacer()
HStack {
ForEach(0..<6) { index in
GeometryReader { geo in
Text("Source \(index)")
.padding()
.foregroundColor(.white)
.background(selectedSource == index ? Color.orange : Color.black)
.onTapGesture {
let size = geo.size
sourceCGPoint = CGPoint(x: geo.frame(in: .global).origin.x + (size.width / 2), y: geo.frame(in: .global).origin.y)
print("Source Global center: \(geo.frame(in: .global).midX) x \(geo.frame(in: .global).midY)")
selectedSource = index
}
}
}
}
Button(isConnected ? "CUT" : "CONNECT") {
isConnected.toggle()
}
.padding()
}
}
}
func getPath() -> some View {
Path { path in
path.move(to: sourceCGPoint)
path.addLine(to: targetCGPoint)
}
.stroke(Color.blue, lineWidth: 10)
}
}

SwiftUI - Expanding the main grid horizontally pushes my other views offscreen

I am working on an iPhone app using SwiftUI. The main grid uses HStacks nested inside a VStack. The number of columns will be variable on initialization, but fixed thereafter. The number of rows is completely dynamic. When the grid expands beyond the width or height of the screen it pushes the other views out of the way.
I would like it to just expand rightward (on initialization) and downward (dynamically) offscreen and behind the other views. I tried putting everything in a ZStack and setting their zindices, but that didn't work. Is there any way to do this or do I need a new approach?
//
// GameView.swift
// Scoreboard
//
// Created by user926153 on 8/21/20.
// Copyright © 2020 user926153. All rights reserved.
//
//
//
/*
This uses the TrackableScrollView as created by Max Natchanon here:
https://medium.com/#maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec
*/
import SwiftUI
import UIKit
struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
struct TrackableScrollView<Content>: View where Content: View {
let axes: Axis.Set
let showIndicators: Bool
#Binding var contentOffset: CGFloat
let content: Content
init(_ axes: Axis.Set = .vertical, showIndicators: Bool = true, contentOffset: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self.axes = axes
self.showIndicators = showIndicators
self._contentOffset = contentOffset
self.content = content()
}
var body: some View {
GeometryReader { outsideProxy in
ScrollView(self.axes, showsIndicators: self.showIndicators) {
ZStack(alignment: self.axes == .vertical ? .top : .leading) {
GeometryReader { insideProxy in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: [self.calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)])
// Send value to the parent
}
VStack {
self.content
}
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
self.contentOffset = value[0]
}
// Get the value then assign to offset binding
}
}
private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
if axes == .vertical {
return (outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY) * -1
} else {
return (outsideProxy.frame(in: .global).minX - insideProxy.frame(in: .global).minX) * -1
}
}
}
struct GameView: View {
let row_label_offset: CGFloat = 80
let col_width: CGFloat = 75
let row_height: CGFloat = 50
#ObservedObject var settings: GameSettings
#State var round_number: Int = 1
#State var scores: [[Int]] = [[0, 0, 0, 0]]
#State var column_offset: CGFloat = 0
#State var row_offset: CGFloat = 0
private func AddRound() {
self.round_number += 1
self.scores.append([0, 0, 0, 0])
}
private func DeleteRound(at offsets: IndexSet) {
// NOTE: also have to delete round from score array
self.round_number -= 1
self.scores.remove(atOffsets: offsets)
}
var body: some View {
VStack {
VStack(alignment: .center, spacing: 10) {
Text("Title")
.font(.title)
Text("subtitle")
}
.border(Color.black)
VStack(alignment: .leading, spacing: 10) {
HStack {
Button(action: {
self.AddRound()
}) {
Text("New round +")
.fixedSize(horizontal: false, vertical: true)
}
.frame(width: self.row_label_offset, height: self.row_height)
TrackableScrollView(.horizontal, showIndicators: false, contentOffset: $column_offset) {
HStack {
ForEach((1...7), id: \.self) {
Text("Player \($0)\n total")
.frame(width: self.col_width, height: self.row_height)
}
}
}
.frame(height: 50)
.border(Color.red)
}
HStack {
TrackableScrollView(.vertical, showIndicators: false, contentOffset: $row_offset) {
ForEach((1...self.round_number), id: \.self) { round in
Text("Round \(round)")
.frame(width: self.col_width, height: self.row_height)
}
}
.border(Color.black)
.frame(width: self.row_label_offset)
VStack {
ForEach(self.scores, id: \.self) { round_score in
HStack {
ForEach(round_score, id: \.self) { score in
Text("\(score)")
.frame(width: self.col_width, height: self.row_height)
}
}
}
Spacer()
}
.offset(x: self.column_offset, y: self.row_offset)
Spacer()
}
.border(Color.blue)
}
Button(action: {
}) {
Text("Finish Game")
}
Spacer()
}
}
}
#if DEBUG
struct GameView_Previews: PreviewProvider {
static var previews: some View {
GameView(settings: GameSettings())
}
}
#endif
Here is fixed part - to avoid layout corruption you have to move dynamic part out-of-current-layout, eg. in overlay of empty space area. Also some minor fixes added, like clipping and alignment.
Tested with Xcode 12 / iOS 14
HStack {
TrackableScrollView(.vertical, showIndicators: false, contentOffset: $row_offset) {
ForEach((1...self.round_number), id: \.self) { round in
Text("Round \(round)")
.frame(width: self.col_width, height: self.row_height)
}
}
.border(Color.black)
.frame(width: self.row_label_offset)
Color.clear.overlay ( // << from here !!
VStack {
ForEach(self.scores, id: \.self) { round_score in
HStack {
ForEach(round_score, id: \.self) { score in
Text("\(score)")
.frame(width: self.col_width, height: self.row_height)
}
}
}
Spacer()
}
.offset(x: self.column_offset, y: self.row_offset)
, alignment: .topLeading)
.clipped()
}
.border(Color.blue)
}

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)
}
}
}
}

How to make view the size of another view in SwiftUI

I'm trying to recreate a portion of the Twitter iOS app to learn SwiftUI and am wondering how to dynamically change the width of one view to be the width of another view. In my case, to have the underline be the same width as the Text view.
I have attached a screenshot to try and better explain what I'm referring to. Any help would be greatly appreciated, thanks!
Also here is the code I have so far:
import SwiftUI
struct GridViewHeader : View {
#State var leftPadding: Length = 0.0
#State var underLineWidth: Length = 100
var body: some View {
return VStack {
HStack {
Text("Tweets")
.tapAction {
self.leftPadding = 0
}
Spacer()
Text("Tweets & Replies")
.tapAction {
self.leftPadding = 100
}
Spacer()
Text("Media")
.tapAction {
self.leftPadding = 200
}
Spacer()
Text("Likes")
}
.frame(height: 50)
.padding(.horizontal, 10)
HStack {
Rectangle()
.frame(width: self.underLineWidth, height: 2, alignment: .bottom)
.padding(.leading, leftPadding)
.animation(.basic())
Spacer()
}
}
}
}
I have written a detailed explanation about using GeometryReader, view preferences and anchor preferences. The code below uses those concepts. For further information on how they work, check this article I posted: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/
The solution below, will properly animate the underline:
I struggled to make this work and I agree with you. Sometimes, you just need to be able to pass up or down the hierarchy, some framing information. In fact, the WWDC2019 session 237 (Building Custom Views with SwiftUI), explains that views communicate their sizing continuously. It basically says Parent proposes size to child, childen decide how they want to layout theirselves and communicate back to the parent. How they do that? I suspect the anchorPreference has something to do with it. However it is very obscure and not at all documented yet. The API is exposed, but grasping how those long function prototypes work... that's a hell I do not have time for right now.
I think Apple has left this undocumented to force us rethink the whole framework and forget about "old" UIKit habits and start thinking declaratively. However, there are still times when this is needed. Have you ever wonder how the background modifier works? I would love to see that implementation. It would explain a lot! I'm hoping Apple will document preferences in the near future. I have been experimenting with custom PreferenceKey and it looks interesting.
Now back to your specific need, I managed to work it out. There are two dimensions you need (the x position and width of the text). One I get it fair and square, the other seems a bit of a hack. Nevertheless, it works perfectly.
The x position of the text I solved it by creating a custom horizontal alignment. More information on that check session 237 (at minute 19:00). Although I recommend you watch the whole thing, it sheds a lot of light on how the layout process works.
The width, however, I'm not so proud of... ;-) It requires DispatchQueue to avoid updating the view while being displayed. UPDATE: I fixed it in the second implementation down below
First implementation
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct GridViewHeader : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 0))
Spacer()
Text("Tweets & Replies").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 1))
Spacer()
Text("Media").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 2))
Spacer()
Text("Likes").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 3))
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.alignmentGuide(.underlineLeading) { d in d[.leading] }
.frame(width: w[activeIdx], height: 2)
.animation(.linear)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
#Binding var widths: [CGFloat]
let idx: Int
func body(content: Content) -> some View {
Group {
if activeIdx == idx {
content.alignmentGuide(.underlineLeading) { d in
DispatchQueue.main.async { self.widths[self.idx] = d.width }
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }
} else {
content.onTapGesture { self.activeIdx = self.idx }
}
}
}
}
Update: Better implementation without using DispatchQueue
My first solution works, but I was not too proud of the way the width is passed to the underline view.
I found a better way of achieving the same thing. It turns out, the background modifier is very powerful. It is much more than a modifier that can let you decorate the background of a view.
The basic steps are:
Use Text("text").background(TextGeometry()). TextGeometry is a custom view that has a parent with the same size as the text view. That is what .background() does. Very powerful.
In my implementation of TextGeometry I use GeometryReader, to get the geometry of the parent, which means, I get the geometry of the Text view, which means I now have the width.
Now to pass the width back, I am using Preferences. There's zero documentation about them, but after a little experimentation, I think preferences are something like "view attributes" if you like. I created my custom PreferenceKey, called WidthPreferenceKey and I use it in TextGeometry to "attach" the width to the view, so it can be read higher in the hierarchy.
Back in the ancestor, I use onPreferenceChange to detect changes in the width, and set the widths array accordingly.
It may all sound too complex, but the code illustrates it best. Here's the new implementation:
import SwiftUI
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct GridViewHeader : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 0))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[0] = $0 })
Spacer()
Text("Tweets & Replies")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 1))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[1] = $0 })
Spacer()
Text("Media")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 2))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[2] = $0 })
Spacer()
Text("Likes")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 3))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[3] = $0 })
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.alignmentGuide(.underlineLeading) { d in d[.leading] }
.frame(width: w[activeIdx], height: 2)
.animation(.linear)
}
}
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().fill(Color.clear).preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
let idx: Int
func body(content: Content) -> some View {
Group {
if activeIdx == idx {
content.alignmentGuide(.underlineLeading) { d in
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }
} else {
content.onTapGesture { self.activeIdx = self.idx }
}
}
}
}
First, to answer the question in the title, if you want to make a shape (view) fit to the size of another view, you can use an .overlay(). The .overlay() gets offered its size from the view it is modifying.
In order to set offsets and widths in your Twitter recreation, you can use a GeometryReader. The GeometryReader has the ability to find its .frame(in:) another coordinate space.
You can use .coordinateSpace(name:) to identify the reference coordinate space.
struct ContentView: View {
#State private var offset: CGFloat = 0
#State private var width: CGFloat = 0
var body: some View {
HStack {
Text("Tweets")
.overlay(MoveUnderlineButton(offset: $offset, width: $width))
Text("Tweets & Replies")
.overlay(MoveUnderlineButton(offset: $offset, width: $width))
Text("Media")
.overlay(MoveUnderlineButton(offset: $offset, width: $width))
Text("Likes")
.overlay(MoveUnderlineButton(offset: $offset, width: $width))
}
.coordinateSpace(name: "container")
.overlay(underline, alignment: .bottomLeading)
.animation(.spring())
}
var underline: some View {
Rectangle()
.frame(height: 2)
.frame(width: width)
.padding(.leading, offset)
}
struct MoveUnderlineButton: View {
#Binding var offset: CGFloat
#Binding var width: CGFloat
var body: some View {
GeometryReader { geometry in
Button(action: {
self.offset = geometry.frame(in: .named("container")).minX
self.width = geometry.size.width
}) {
Rectangle().foregroundColor(.clear)
}
}
}
}
}
The underline view is is a 2 point high Rectangle, put in an .overlay() on top of the HStack.
The underline view is aligned to .bottomLeading, so that we can programmatically set its .padding(.leading, _) using a #State value.
The underline view's .frame(width:) is also set using a #State value.
The HStack is set as the .coordinateSpace(name: "container") so we can find the frame of our buttons relative to this.
The MoveUnderlineButton uses a GeometryReader to find its own width and minX in order to set the respective values for the underline view
The MoveUnderlineButton is set as the .overlay() for the Text view containing the text of that button so that its GeometryReader inherits its size from that Text view.
Give this a try:
import SwiftUI
var titles = ["Tweets", "Tweets & Replies", "Media", "Likes"]
struct GridViewHeader : View {
#State var selectedItem: String = "Tweets"
var body: some View {
HStack(spacing: 20) {
ForEach(titles.identified(by: \.self)) { title in
HeaderTabButton(title: title, selectedItem: self.$selectedItem)
}
.frame(height: 50)
}.padding(.horizontal, 10)
}
}
struct HeaderTabButton : View {
var title: String
#Binding var selectedItem: String
var isSelected: Bool {
selectedItem == title
}
var body: some View {
VStack {
Button(action: { self.selectedItem = self.title }) {
Text(title).fixedSize(horizontal: true, vertical: false)
Rectangle()
.frame(height: 2, alignment: .bottom)
.relativeWidth(1)
.foregroundColor(isSelected ? Color.accentColor : Color.clear)
}
}
}
}
And here's what it looks like in preview:
Let me modestly suggest a slight modification of this bright answer:
Version without using preferences:
import SwiftUI
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct GridViewHeader : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 0))
Spacer()
Text("Tweets & Replies").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 1))
Spacer()
Text("Media").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 2))
Spacer()
Text("Likes").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 3))
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.alignmentGuide(.underlineLeading) { d in d[.leading] }
.frame(width: w[activeIdx], height: 2)
.animation(.linear)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
#Binding var widths: [CGFloat]
let idx: Int
func body(content: Content) -> some View {
var w: CGFloat = 0
return Group {
if activeIdx == idx {
content.alignmentGuide(.underlineLeading) { d in
w = d.width
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }.onAppear(perform: {self.widths[self.idx] = w})
} else {
content.onTapGesture { self.activeIdx = self.idx }
}
}
}
}
Version using preferences and GeometryReader:
import SwiftUI
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct GridViewHeader : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 0, widthStorage: $w))
Spacer()
Text("Tweets & Replies")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 1, widthStorage: $w))
Spacer()
Text("Media")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 2, widthStorage: $w))
Spacer()
Text("Likes")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 3, widthStorage: $w))
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.frame(width: w[activeIdx], height: 2)
.animation(.linear)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
let idx: Int
#Binding var widthStorage: [CGFloat]
func body(content: Content) -> some View {
Group {
if activeIdx == idx {
content.background(GeometryReader { geometry in
return Color.clear.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
})
.alignmentGuide(.underlineLeading) { d in
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.widthStorage[self.idx] = $0 })
} else {
content.onTapGesture { self.activeIdx = self.idx }.onPreferenceChange(WidthPreferenceKey.self, perform: { self.widthStorage[self.idx] = $0 })
}
}
}
}
Here's a super simple solution, although it doesn't account for the tabs being stretched full width - but that should just be minor additional math for calculating the padding.
import SwiftUI
struct HorizontalTabs: View {
private let tabsSpacing = CGFloat(16)
private func tabWidth(at index: Int) -> CGFloat {
let label = UILabel()
label.text = tabs[index]
let labelWidth = label.intrinsicContentSize.width
return labelWidth
}
private var leadingPadding: CGFloat {
var padding: CGFloat = 0
for i in 0..<tabs.count {
if i < selectedIndex {
padding += tabWidth(at: i) + tabsSpacing
}
}
return padding
}
let tabs: [String]
#State var selectedIndex: Int = 0
var body: some View {
VStack(alignment: .leading) {
HStack(spacing: tabsSpacing) {
ForEach(0..<tabs.count, id: \.self) { index in
Button(action: { self.selectedIndex = index }) {
Text(self.tabs[index])
}
}
}
Rectangle()
.frame(width: tabWidth(at: selectedIndex), height: 3, alignment: .bottomLeading)
.foregroundColor(.blue)
.padding(.leading, leadingPadding)
.animation(Animation.spring())
}
}
}
HorizontalTabs(tabs: ["one", "two", "three"]) renders this:
You just need to specify a frame with a height within it. Here's an example :
VStack {
Text("First Text Label")
Spacer().frame(height: 50) // This line
Text("Second Text Label")
}
This solution is very wonderful.
But it became a compilation error now, it corrected. (Xcode11.1)
This is a whole code.
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct WidthPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct HorizontalTabsView : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 0))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[0] = $0 })
Spacer()
Text("Tweets & Replies")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 1))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[1] = $0 })
Spacer()
Text("Media")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 2))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[2] = $0 })
Spacer()
Text("Likes")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 3))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[3] = $0 })
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.alignmentGuide(.underlineLeading) { d in d[.leading] }
.frame(width: w[activeIdx], height: 2)
.animation(.default)
}
}
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle()
.foregroundColor(.clear)
.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
let idx: Int
func body(content: Content) -> some View {
Group {
if activeIdx == idx {
content.alignmentGuide(.underlineLeading) { d in
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }
} else {
content.onTapGesture { self.activeIdx = self.idx }
}
}
}
}
struct HorizontalTabsView_Previews: PreviewProvider {
static var previews: some View {
HorizontalTabsView()
}
}

Resources