SwiftUI - Position a rectangle relative to another view in ZStack - swift

The task
I am creating a bar chart with SwiftUI and I am looking for the best approach to position the average indicator, the horizontal yellow line, shown in the picture below:
The goal
I need to be able to position the yellow line (rectangle with 1 point in height) relative to the bars (also rectangles) of the chart. For simplicity let us imagine that the tallest possible bar is 100 points and the average is 75. My goal is to draw the yellow line exactly 75 points above the bottom of the bars.
The problem
In my example I am placing the line on top of the chart using a ZStack. I can position the line vertically using a GeometryReader, however I do not know how to position it in reference to the bottom of the bars.
Here my playground code:
import SwiftUI
import PlaygroundSupport
struct BarChart: View {
var measurements : [Int]
var pastColor: Color
var todayColor: Color
var futureColor: Color
let labelsColor = Color(UIColor(white: 0.5, alpha: 1.0))
func monthAbbreviationFromInt(_ day: Int) -> String {
let da = Calendar.current.shortWeekdaySymbols
return da[day]
}
var body: some View {
ZStack {
VStack {
HStack {
Rectangle()
.fill(Color.yellow)
.frame(width: 12, height: 2, alignment: .center)
Text("Desired level")
.font(.custom("Helvetica", size: 10.0))
.foregroundColor(labelsColor)
}
.padding(.bottom , 50.0)
HStack {
ForEach(0..<7) { day in
VStack {
Spacer()
Text(String(self.measurements[day]))
.frame(width: CGFloat(40), height: CGFloat(20))
.font(.footnote)
.lineLimit(1)
.minimumScaleFactor(0.5)
.foregroundColor(self.labelsColor)
Rectangle()
.fill(day > 0 ? self.futureColor : self.pastColor)
.frame(width: 20, height: CGFloat(self.measurements[day]*2))
.transition(.slide)
.cornerRadius(3.0)
Text("\(self.monthAbbreviationFromInt(day))\n\(day)")
.multilineTextAlignment(.center)
.font(.footnote)
.frame(height: 20)
.lineLimit(2)
.minimumScaleFactor(0.2)
.foregroundColor(self.labelsColor)
}
.frame(height: 200, alignment: .bottom )
}
}
}
.padding()
VStack {
GeometryReader { geometry in
//Yellow line
Rectangle()
.path(in: CGRect(x: 0,
y: 350, //This is now a random value, but I should detect and use the bottom coordinate of the bars to place the line at exactly 75 points above that coordinate
width: geometry.size.width,
height: 1))
.fill(Color.yellow)
}
}
}
}
}
How can I get the relative position of the bottom of the bars so that I can place the line correctly?
Is there another approach you would suggest?
Thank you in advance

example
import SwiftUI
struct Data: Identifiable {
let id = UUID()
let value: CGFloat
let color: Color
}
struct ContentView: View {
let data = [Data(value: 35, color: .red),
Data(value: 100, color: .green),
Data(value: 70, color: .yellow),
Data(value: 25, color: .blue),
Data(value: 55, color: .orange)]
var maxValue: CGFloat {
data.map { (element) -> CGFloat in
element.value
}.max() ?? 1.0
}
var average: CGFloat {
guard !data.isEmpty else { return 0 }
let sum = data.reduce(into: CGFloat.zero) { (res, data) in
res += data.value
}
return sum / CGFloat(data.count)
}
var body: some View {
HStack {
ForEach(data) { (bar) in
Bar(t: bar.value / self.maxValue, color: bar.color, width: 20)
}
}
.background(Color.pink.opacity(0.1))
.overlay(
GeometryReader { proxy in
Color.primary.frame(height: 1).position(x: proxy.size.width / 2, y: proxy.size.height * ( 1 - self.average / self.maxValue))
}
).padding(.vertical, 100).padding(.horizontal, 20)
}
}
struct Bar: View {
let t: CGFloat
let color: Color
let width: CGFloat
var body: some View {
GeometryReader { proxy in
Path { (path) in
path.move(to: .init(x: proxy.size.width / 2, y: proxy.size.height))
path.addLine(to: .init(x: proxy.size.width / 2, y: 0))
}
.trim(from: 0, to: self.t)
.stroke(lineWidth: self.width)
.foregroundColor(self.color)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
and result
or slightly adopted to show labels

Related

Understanding Preferences in SwiftUI

iOS 15 Swift 5.5 Trying to understand preferences, but struggling with GeometryReader
I crafted this code reading a tutorial, but it doesn't work correctly. The green box should appear over the red dot, the first one. But it is over the centre one. When I tap on the dot, it moves to the third...it feels like SwiftUI drew the whole thing and then changed its mind about the coordinates. Sure I could change the offsets, but that is a hack. Surely SwiftUI should be updating the preferences if the thing moves.
import SwiftUI
struct ContentView: View {
#State private var activeIdx: Int = 0
#State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 12)
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
.frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
.offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY)
HStack {
SubtleView(activeIdx: $activeIdx, idx: 0)
SubtleView(activeIdx: $activeIdx, idx: 1)
SubtleView(activeIdx: $activeIdx, idx: 2)
}
.animation(.easeInOut(duration: 1.0), value: rects)
}.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
for p in preferences {
self.rects[p.viewIdx] = p.rect
print("p \(p)")
}
}.coordinateSpace(name: "myZstack")
}
}
struct SubtleView: View {
#Binding var activeIdx:Int
let idx: Int
var body: some View {
Circle()
.fill(Color.red)
.frame(width: 64, height: 64)
.background(MyPreferenceViewSetter(idx: idx))
.onTapGesture {
activeIdx = idx
}
}
}
struct MyTextPreferenceData: Equatable {
let viewIdx: Int
let rect: CGRect
}
struct MyTextPreferenceKey: PreferenceKey {
typealias Value = [MyTextPreferenceData]
static var defaultValue: [MyTextPreferenceData] = []
static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
struct MyPreferenceViewSetter: View {
let idx: Int
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: MyTextPreferenceKey.self,
value: [MyTextPreferenceData(viewIdx: self.idx, rect: geometry.frame(in: .named("myZstack")))])
}
}
}
Tried moving the onPreferences up a level, down a level..attaching it to each view... but still doesn't update...what did I miss here=
The problem is that you are using offset(x:y:) instead of position(x:y:).
Your ZStack's coordinates will start from the top-left which will (locally) be (0, 0). However, by default the green square will be centered between all 3 red circles. This causes a problem because the coordinates start at (0, 0) but the square starts centered (i.e. one square off).
Initially you have the first circle starting at (0, 0) but you are already have a centered square, so the offset is 0 in both x and y, therefore already centered and it appears like you are on a different index.
Fix this by replacing with position(x:y:) and by measuring the mid points:
RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
.frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
.position(x: rects[activeIdx].midX, y: rects[activeIdx].midY)
You read absolute coordinates in preference so need to use position instead of offset
RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
.frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
.position(x: rects[activeIdx].midX, y: rects[activeIdx].midY) // << here !!
Tested with Xcode 13.2 / iOS 15.2
And here is complete fixed body:
var body: some View {
ZStack {
HStack {
SubtleView(activeIdx: $activeIdx, idx: 0)
SubtleView(activeIdx: $activeIdx, idx: 1)
SubtleView(activeIdx: $activeIdx, idx: 2)
}
RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
.frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
.position(x: rects[activeIdx].midX, y: rects[activeIdx].midY)
}.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
for p in preferences {
self.rects[p.viewIdx] = p.rect
print("p \(p)")
}
}.coordinateSpace(name: "myZstack")
.animation(.easeInOut(duration: 0.5), value: activeIdx)
I think the answer is simply that a ZStack is naturally center aligned, so your initial position, before the offset is over the second circle, but your activeIdx is 0 which is the first circle. If you view it on the Canvas in the static preview. Simply changing the ZStack alignment to .leading lines everything up.
var body: some View {
ZStack(alignment: .leading) { // Set your alignment here
RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
.frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
.offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY)
HStack {
SubtleView(activeIdx: $activeIdx, idx: 0)
SubtleView(activeIdx: $activeIdx, idx: 1)
SubtleView(activeIdx: $activeIdx, idx: 2)
}
// Also change the animation value to activeIdx
.animation(.easeInOut(duration: 1.0), value: activeIdx)
}.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
for p in preferences {
self.rects[p.viewIdx] = p.rect
print("p \(p)")
}
}.coordinateSpace(name: "myZstack")
}

Increase/Decrease the size of a view horizontally by dragging the edges of it

I've seen a few similar examples of this such as How to correctly do up an adjustable split view in SwiftUI? and How to resize UIView by dragging from its edges? but I can't find exactly what I'm looking for that correlates correctly across to SwiftUI
I have a view that I want the user to be able to adjust the width of via a 'grab bar' on the right of the view. When the user drags this bar left (decreases view width) and to the right (increases the view width). How can I go about doing this?
In the example, RedRectangle is my view that i'm trying to adjust which comprises of a Rectangle and the Resizer which is manipulated to adjust the size. What am I doing wrong here?
Additionally, there isn't a gradual animation/transition of the frame being increased/decreased and it just seems to jump. How can I achieve this?
Reproducible example linked here:
import SwiftUI
struct ContentView: View {
#State var resizedWidth: CGFloat?
var body: some View {
HStack(alignment: .center) {
Spacer()
RedRectangle(width: 175, resizedWidth: resizedWidth)
Resizer()
.gesture(
DragGesture()
.onChanged({ value in
resizedWidth = max(80, resizedWidth ?? 0 + value.translation.width)
})
)
Spacer()
}
}
}
struct RedRectangle: View {
let width: CGFloat
var resizedWidth: CGFloat?
var body: some View {
Rectangle()
.fill(Color.red)
.frame(width: resizedWidth != nil ? resizedWidth : width, height: 300)
.frame(minWidth: 80, maxWidth: 400)
}
}
struct Resizer: View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 8, height: 75)
.cornerRadius(10)
}
}
import SwiftUI
struct ContentView: View {
let minWidth: CGFloat = 100
#State var width: CGFloat?
var body: some View {
HStack(alignment: .center) {
Spacer()
RedRectangle(width: width ?? minWidth)
Resizer()
.gesture(
DragGesture()
.onChanged { value in
width = max(minWidth, width! + value.translation.width)
}
)
Spacer()
}
.onAppear {
width = minWidth
}
}
}
struct RedRectangle: View {
let width: CGFloat
var body: some View {
Rectangle()
.fill(Color.red)
.frame(width: width, height: 100)
}
}
struct Resizer: View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 8, height: 75)
.cornerRadius(10)
}
}

Drawing Rectangles using a ForEach loop SwiftUI

Im trying to create a view of a rectangle with a given amount of rectangles inside of it,
i.e.Image
Im trying to recreate this with the option to pass in a size to add more levels to it, currently I've only managed to overlay
let colors: [Color] = [.red, .green, .blue]
var body: some View {
ZStack {
ForEach(colors, id: \.self) { color in
Text(color.description.capitalized)
.background(color)
}
}
}
which I found only whilst looking for the problem, the only issue I have is having adjustable size values which while using the loop can get progressively smaller, currently the closest I've got is this:
struct SquareContent{
var color: Color
var size: CGSize
}
struct GrannySquareComplete: View {
let colors: [SquareContent] = [
SquareContent.init(color: .red, size: CGSize(width: 100, height: 100)),
SquareContent.init(color: .white, size: CGSize(width: 80, height: 80)),
SquareContent.init(color: .red, size: CGSize(width: 80, height: 80))]
var body: some View {
ZStack {
ForEach(0...colors.count) { i in
Text("")
.frame(width: colors[i].size.width, height: colors[i].size.height)
.background(colors[i].color)
.border(.white)
}
}
}
}
but this returns an error of
No exact matches in call to initialiser
I believe this is due to the use of Text within the foreach loop but cannot figure out how to fix this.
To fix your error change ForEach(0...colors.count) { i in to
ForEach(0..<colors.count) { i in
To make it dynamic
//Make Identifiable
struct SquareContent: Identifiable{
//Add id
let id: UUID = UUID()
var color: Color
//Handle size based on screen
}
struct GrannySquareComplete: View {
//Change to #State
#State var colors: [SquareContent] = [
SquareContent.init(color: .red),
SquareContent.init(color: .white),
SquareContent.init(color: .red)]
var body: some View {
VStack{
//Mimic adding a square
Button("add square"){
colors.append(.init(color: [.blue, .black,.gray, .orange, .pink].randomElement()!))
}
ZStack(alignment: .center){
ForEach(Array(colors.enumerated()), id: \.offset) { (index, color) in
//Calculate the size by percentange based on index
let percentage: CGFloat = Double(colors.count - index)/Double(colors.count)
//Create a custom view to handle the details
SquareComplete(square: color, percentage: percentage)
}
}
}
}
}
struct SquareComplete: View {
let square: SquareContent
let percentage: CGFloat
var body: some View{
GeometryReader{ geo in
Rectangle()
//Make a square
.aspectRatio(1, contentMode: .fit)
.border(.white)
.background(square.color)
.foregroundColor(.clear)
//Center
.position(x: geo.size.width/2, y: geo.size.height/2)
//Adjust size by using the available space and the passed percentage
.frame(width: geo.size.width * percentage, height: geo.size.height * percentage, alignment: .center)
}
}
}
struct GrannySquareComplete_Previews: PreviewProvider {
static var previews: some View {
GrannySquareComplete()
}
}

Working with dial view in SwiftUI with drag gesture and displaying value of selected number

I'm new to SwiftUI and what I trying to build is a dial view like this.
As you can see it has:
a view indicator which is for displaying the currently selected value from the user.
CircularDialView with all the numbers to chose from.
text view for displaying actual selected number
Maximum what I tried to accomplish is that for now CircularDialView is intractable with a two-finger drag gesture and for the rest, I have no even close idea how to do it.
Please help, because I tried everything I could and have to say it's super hard to do with SwiftUI
Here's the code:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Rectangle()
.frame(width: 4, height: 25)
.padding()
CircularDialView()
Text("\(1)")
.font(.title)
.fontWeight(.bold)
.padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct ClockText: View {
var numbers: [String]
var angle: CGFloat
private struct IdentifiableNumbers: Identifiable {
var id: Int
var number: String
}
private var dataSource: [IdentifiableNumbers] {
numbers.enumerated().map { IdentifiableNumbers(id: $0, number: $1) }
}
var body: some View {
GeometryReader { geometry in
ZStack {
ForEach(dataSource) {
Text("\($0.number)")
.position(position(for: $0.id, in: geometry.frame(in: .local)))
}
}
}
}
private func position(for index: Int, in rect: CGRect) -> CGPoint {
let rect = rect.insetBy(dx: angle, dy: angle)
let angle = ((2 * .pi) / CGFloat(numbers.count) * CGFloat(index)) - .pi / 2
let radius = min(rect.width, rect.height) / 2
return CGPoint(x: rect.midX + radius * cos(angle),
y: rect.midY + radius * sin(angle))
}
}
struct CircularDialView: View {
#State private var rotateState: Double = 0
var body: some View {
ZStack {
Circle()
.stroke(Color.black, lineWidth: 5)
.frame(width: 250, height: 250, alignment: .center)
ClockText(
numbers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map {"\($0)"},
angle: 45
)
.font(.system(size: 20))
.frame(width: 290, height: 290, alignment: .center)
}
.rotationEffect(Angle(degrees: rotateState))
.gesture(
RotationGesture()
.simultaneously(with: DragGesture())
.onChanged { value in
self.rotateState = value.first?.degrees ?? rotateState
}
)
}
}

How do I make all views the same height in a SwiftUI View with an HStack?

I want a simple graph with a colored rectangle of variable height for each data point.
The white space below the colored rectangle should expand so that the bottom numbers line up, the way the top row of numbers does.
This is my view:
So I would like an idiomatic solution to getting the bottom row of numbers to line up with the 59. Any advice that points me in the right direction is welcome. Thanks.
Here's what I have so far:
struct DemoView: View {
var dataSource = [1, 0, 34, 12, 59, 44]
/// Provide a Dynamic Height Based on the Tallest View in the Row
#State private var height: CGFloat = .zero // < calculable height
/// The main view is a row of variable height views
var body: some View {
HStack(alignment: .top) {
Spacer()
/// i want these to all be the same height
ForEach(0 ..< 6) { index in
VStack {
Text("\(index)")
Rectangle()
.fill(Color.orange)
.frame(width: 20, height: CGFloat(self.dataSource[index]))
Text("\(dataSource[index])")
}
}
Spacer()
}
.alignmentGuide(.top, computeValue: { d in
DispatchQueue.main.async {
self.height = max(d.height, self.height)
}
return d[.top]
})
}
}
struct Demo_Preview: PreviewProvider {
static var previews: some View {
DemoView()
}
}
Edit to show the final results:
I made the changes Asperi suggested, changed the .top alignments to .bottom and got a very nice simple chart:
Here is possible (seems simplest) approach. Tested with Xcode 12 / iOS 14
struct DemoView: View {
var dataSource = [1, 0, 34, 12, 59, 44]
var body: some View {
HStack(alignment: .top) {
Spacer()
/// i want these to all be the same height
ForEach(0 ..< 6) { index in
VStack {
Text("\(index)")
Color.clear
.frame(width: 20, height: CGFloat(dataSource.max() ?? 0))
.overlay(
Color.orange
.frame(height: CGFloat(self.dataSource[index]))
, alignment: .top)
Text("\(dataSource[index])")
}
}
Spacer()
}
}
}