ScrollView force unecessary shape redraw - swiftui

I have a complex shape in a view contained in a scroll view. Each time I scroll the view the shape is drawn making the application clumsy and unresponsive.
This a simple test code.
struct TestView: View {
#ObservedObject private var viewModel = MainViewModel()
var body: some View {
VStack {
....
ScrollView(.horizontal) {
SubView()
.stroke()
.frame(minWidth: 700)
}
}
}
}
struct SubView: Shape {
init() {
print("init")
}
func path(in rect: CGRect) -> Path {
dprint("drawing")
let newPath = Path { path in
for _ in 0..<600 {
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: rect.size.width, y: rect.size.height))
}
}
return newPath
}
}
I tried to change the code in order to cache the path:
struct SubView: Shape {
var cachedPath: Path?
init() {
print("init")
}
func path(in rect: CGRect) -> Path {
dprint("drawing")
if let path = cachedPath {
dprint("cached path")
return path
}
let newPath = Path { path in
for _ in 0..<600 {
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: rect.size.width, y: rect.size.height))
}
}
cachedPath = newPath // <-- Cannot assign to property: 'self' is immutable
return newPath
}
}
but I get the following error:
Cannot assign to property: 'self' is immutable
How do I avoid the shape to be redrawn each time a scroll it?

I ended up integrating a custom UIView using UIViewRepresentable. It is super fast and scrolling the view doesn't re-launch the draw(_ rect: CGRect).
Sure it's not a pure SwiftUI solution but it's exactly what I need.

Related

SwiftUI UIRepresentable aspect ratio changes when device is rotated

I have the following code
class UIViewTestClass: UIView {
override func draw(_ rect: CGRect) {
"Test".draw(at: CGPoint(x: rect.midX, y: rect.midY))
let context = UIGraphicsGetCurrentContext()
context?.addRect(rect)
context?.stroke(rect)
}
}
struct TestUIView: UIViewRepresentable {
typealias UIViewType = UIView
func makeUIView(context: Context) -> UIView {
let result = UIViewTestClass()
result.backgroundColor = UIColor(.white)
return result
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
struct ContentView: View {
var body: some View {
TestUIView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
// .aspectRatio(contentMode: .fit)
.padding()
}
}
It works fine when the view is presented but when the device is rotated the letters get stretched out or squeezed. Also, if I include the aspectRatio(contentMode: .fit) the text is correct but the view now only takes up a part of the frame.
How can I prevent this from happening?
For a UIView, the draw(_ rect: CGRect) func is only called when UIKit decides it needs to be called.
If you put a print("inside draw()") statement in there, you'll see that it is not called when you rotate the device.
For UIKit implementation, we can implement layoutSubviews() add a call to setNeedsDisplay() to tell UIKit to call draw().
I don't use SwiftUI ... but quick try looks like this will work:
class UIViewTestClass: UIView {
override func draw(_ rect: CGRect) {
"Test".draw(at: CGPoint(x: rect.midX, y: rect.midY))
let context = UIGraphicsGetCurrentContext()
context?.addRect(rect)
context?.stroke(rect)
}
override func layoutSubviews() {
super.layoutSubviews()
setNeedsDisplay()
}
}

Draw Path depending on currently visible views

This is the view I am trying to create:
Everything except the orange line is in place and working (the green ones are just helper lines for visualisation).
I am trying to retrieve informations on rendered views and set view properties to their rect to calculate the orange Path. Right now, this results in the Modifying state during view update, this will cause undefined behavior. error.
What do I need to change in order to make this work?
Here's my view:
struct MyView: View {
#State private var titleRect: CGRect = .zero
#State private var dividerRect: CGRect = .zero
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25.0)
.foregroundColor(.yellow)
VStack {
Text("Some fancy title …")
.border(.green) //
.background {
GeometryReader { proxy -> Color in
titleRect = proxy.frame(in: .named("root"))
return .clear
}
}
Rectangle()
.frame(height: 20.0)
.foregroundColor(.clear)
Rectangle()
.frame(height: 1.0)
.background {
GeometryReader { proxy -> Color in
dividerRect = proxy.frame(in: .named("root"))
return .clear
}
}
Spacer()
}
.padding(.top)
Path { path in
path.move(to: CGPoint(x: dividerRect.minX, y: titleRect.maxY)) // startingPoint
path.addLine(to: CGPoint(x: dividerRect.width * 2/3, y: dividerRect.maxY)) // midPoint
path.addLine(to: CGPoint(x: dividerRect.maxX, y: titleRect.maxY)) // endPoint
}
}
.padding()
.coordinateSpace(name: "root")
}
}
I think I found a good workaround for my problem by implementing a ViewModifier that can get me the frame size for arbitrary views I need it for:
struct MeasureSizeModifier: ViewModifier {
let callback: (CGSize) -> ()
func body(content: Content) -> some View {
content
.background {
GeometryReader { proxy in
Color.clear
.onAppear {
callback(proxy.size)
}
}
}
}
}
extension View {
func measureSize(_ callback: #escaping (CGSize) -> () ) -> some View {
modifier(MeasureSizeModifier(callback: callback))
}
}
Credits go out to "Flo Writes Code" on YouTube for his tutorial on this: How to use GeometryReader
BEST in SwiftUl!
After having implemented what is suggested, I can use this extension like so in my specific case:
struct MyView: View {
#State private var containerWidth: CGFloat = .zero
#State private var titleHeight: CGFloat = .zero
var body: some View {
ZStack(alignment: .top) {
RoundedRectangle(cornerRadius: 25.0)
.measureSize { size in
containerWidth = size.width
print(containerWidth)
}
.foregroundColor(.yellow)
Text("Some fancy title …")
.border(.green)
.measureSize { size in
titleHeight = size.height
}
Path { path in
path.move(to: CGPoint(x: .zero, y: titleHeight))
path.addLine(to: CGPoint(x: containerWidth * 0.67, y: titleHeight * 3))
path.addLine(to: CGPoint(x: containerWidth, y: titleHeight))
path.move(to: CGPoint(x: .zero, y: titleHeight * 3))
path.addLine(to: CGPoint(x: containerWidth, y: titleHeight * 3))
}
.stroke(.black)
.border(.green)
HStack {
Text("Y")
Spacer()
Text("N")
}
.padding(.horizontal, 8)
.position(x: containerWidth / 2, y: titleHeight * 2)
.frame(width: containerWidth)
}
.padding()
}
}
I will have other places where I will be glad to have this extension in my toolbelt 🎉

Is there a way to add paths to an already rendered path

TL;DR - I would like to add a Path or a Shape ? to an existing Shape and would like to know if I can somehow use path.addPath(...) See image for a visual of what I mean
I posted a question earlier about this but don't think I phrased it well and very possibly went about it the wrong way. All I would like to do is to add shapes to an existing shape. I see that Path has a method addPath(:) that lets us add one path to another.. and that would be perfect if I could figure out where to do it at. Here is my current attempt:
import SwiftUI
struct ContentView: View {
#State private var path = Path()
var body: some View {
VStack {
MyShape()
.stroke(lineWidth: 3)
.padding()
HStack {
AddHorizontalLineView()
AddVerticalLineView()
AddSquareView()
AddDiagonalLineView()
}
}
}
private func addBoxToPath(_ path: Path) -> Path { <-- No where to call this
var workingPath = path
workingPath.addRect(workingPath.boundingRect)
return workingPath
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct MyShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.addRect(rect)
return path
}
}
struct AddHorizontalLineView: View {
var body: some View {
Button(action: {}){
Rectangle()
.stroke()
.overlay(
HorizontalWire()
.stroke()
.padding()
)
}.frame(width: 50, height: 50)
}
}
struct AddVerticalLineView: View {
var body: some View {
Button(action: { }){
Rectangle()
.stroke()
.overlay(
VerticalWire()
.stroke()
.padding()
)
}.frame(width: 50, height: 50)
}
}
struct AddSquareView: View {
var body: some View {
Button(action: {
}){
Rectangle()
.stroke()
.overlay(
Rectangle()
.stroke()
.padding()
)
}.frame(width: 50, height: 50)
}
}
struct AddDiagonalLineView: View {
var body: some View {
Button(action: {}){
Rectangle()
.stroke()
.overlay(
DiagonalWire()
.stroke()
.foregroundColor(.red)
.padding()
)
}
.frame(width: 50, height: 50)
}
}
struct DiagonalWire: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
return path
}
}
struct VerticalWire: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
return path
}
}
struct HorizontalWire: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.midY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
return path
}
}
Can paths be added dynamically like this or the entire setup needs to be done beforehand in path(in:) method of every type that conforms to Shape
I hope my question makes more sense now..
You can pass the Path into MyShape (although it really has very little point then -- you could just render the Path itself).
Here's a simple example (with some of your buttons removed since it'll be a very amount of work to hook up all of the logic). I also had to add an additional rect to the Path to start with, since your addBoxToPath depends on there being something in the Path so that the boundingBox isn't just a 0x0 rect:
struct ContentView: View {
#State private var path = Path()
var body: some View {
VStack {
MyShape(path: path)
.stroke(lineWidth: 3)
.padding()
HStack {
Button(action: {
path = addBoxToPath(path)
}) {
Text("add")
}
}
}.onAppear {
path.addRect(CGRect(origin: .zero, size: CGSize(width: 200, height: 200)))
}
}
private func addBoxToPath(_ path: Path) -> Path {
var workingPath = path
workingPath.addRect(workingPath.boundingRect.insetBy(dx: -5, dy: -5))
return workingPath
}
}
struct MyShape: Shape {
var path : Path
func path(in rect: CGRect) -> Path {
return path
}
}

How would I modify an already drawn Path?

So I have a view of lines. The number of lines are determined by the user's input. I know about creating lines and how to render those lines with different effects.. but how would I modify a line that has already been created.
So for example I would like to make the second drawn line have a break in the middle after maybe a button tap from the user..
Here is some minimally reproduced code..
import SwiftUI
struct ContentView: View {
#State private var path = Path()
#State private var horizontalLines = 0
var body: some View {
GeometryReader { geo in
VStack {
Draw(path: $path)
.stroke()
.clipped()
.padding()
HorizontalLine(path: $path, horizontalLines: $horizontalLines, rect: geo.frame(in: .local))
}
}
}
private func addLine() {
path.addEllipse(in: path.boundingRect)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct Draw: Shape {
#Binding var path: Path
func path(in rect: CGRect) -> Path {
var path = path
path.addRect(rect)
return path
}
}
struct HorizontalLine: View {
#Binding var path: Path
#Binding var horizontalLines: Int
let rect: CGRect
var verticalOffset: CGFloat {
let height = rect.size.height
let tenths = height / 10
return tenths * CGFloat(horizontalLines)
}
var body: some View {
Button("Add Horizontal Lines") {
path = addHLine()
}
}
private func addHLine() -> Path {
var path = path
path.move(to: CGPoint(x: rect.minX + 50, y: verticalOffset))
path.addLine(to: CGPoint(x: rect.maxX - 75 , y: verticalOffset))
self.horizontalLines += 1
return path
}
}

In SwiftUI, how do I create a dynamic rectangle for a bar chart?

I'm trying to create a custom percentage bar chart like the one below
however, I'm unable to set the bar width programmatically, what am I doing wrong?
Setting .frame(width: UIScreen.main.bounds.width * percent, height:23) produces the following error : Ambiguous reference to member 'frame(width:height:alignment:)'
import SwiftUI
struct BarChartView : View {
#Binding var percent: Float
init(percentage: Binding<Float>){
self._percent = percentage
}
var body: some View {
ZStack {
Rectangle().fill(Color.yellow).frame(height: 30).border(Color.black, width:1)
HStack {
RoundedRectangle(cornerRadius: 5)
.fill(Color.green).frame(width: 300, height:23).padding(2)
Spacer()
}
HStack {
Text("Bar Chart View").padding (2)
Spacer()
Text("\(String(format: "%02.f", arguments: [self.percent]))%")
}
}
}
}
Is there a way to determine the width of the first rectangle in the ZStack and calc a percentage off of that. I would like this to automatically update in landscape mode too, if possible.
You could use GeometryReader to play with dimensions, but in this case I think using a shape is a much better fit:
import SwiftUI
struct BarChartView : View {
#Binding var percent: Float
init(percentage: Binding<Float>){
self._percent = percentage
}
var body: some View {
HStack {
Text("Bar Chart View").padding (5)
Spacer()
Text("\(String(format: "%02.f", arguments: [self.percent * 100]))%").padding(5)
}
.background(LeftPart(pct: CGFloat(percent)).fill(Color.green))
.background(RightPart(pct: CGFloat(percent)).fill(Color.yellow))
.padding(10)
}
struct LeftPart: Shape {
let pct: CGFloat
func path(in rect: CGRect) -> Path {
var p = Path()
p.addRoundedRect(in: CGRect(x: 0, y: 0, width: rect.size.width * pct, height: rect.size.height), cornerSize: .zero)
return p
}
}
struct RightPart: Shape {
let pct: CGFloat
func path(in rect: CGRect) -> Path {
var p = Path()
p.addRoundedRect(in: CGRect(x: rect.size.width * pct, y: 0, width: rect.size.width * (1-pct), height: rect.size.height), cornerSize: .zero)
return p
}
}
}
struct ContentView: View {
var body: some View {
BarChartView(percentage: .constant(0.75))
}
}