I'm trying to create an equally spaced circular ruler using SwiftUI but there always appears to be one glitch when I use this code:
struct ContentView: View {
let magicNumber: CGFloat = 20
var body: some View {
VStack {
GeometryReader { geo in
Circle()
.stroke(Color.red, style: StrokeStyle(lineWidth: geo.size.width/5, lineCap: .butt, dash: [1, magicNumber]))
.padding(geo.size.width/10)
}
}.padding()
}
}
As can be seen here...
I've separated out the "magicNumber" variable in the hope there is a function/equation I could use to determine what it should be for the spacing to always be equal. Any tips? Or perhaps there is a better way to achieve this?
Rather than trying to dynamically calculate a magic number, maybe just better to define a shape and tell it how many sections you want. That way you can be guaranteed that they'll be evenly spaced.
struct ContentView : View {
var body: some View {
MyShape(sections: 20, lineLengthPercentage: 0.3)
.stroke(Color.red)
}
}
struct MyShape : Shape {
var sections : Int
var lineLengthPercentage: CGFloat
func path(in rect: CGRect) -> Path {
let radius = rect.width / 2
let degreeSeparation : Double = 360.0 / Double(sections)
var path = Path()
for index in 0..<Int(360.0/degreeSeparation) {
let degrees = Double(index) * degreeSeparation
let center = CGPoint(x: rect.midX, y: rect.midY)
let innerX = center.x + (radius - rect.size.width * lineLengthPercentage / 2) * CGFloat(cos(degrees / 360 * Double.pi * 2))
let innerY = center.y + (radius - rect.size.width * lineLengthPercentage / 2) * CGFloat(sin(degrees / 360 * Double.pi * 2))
let outerX = center.x + radius * CGFloat(cos(degrees / 360 * Double.pi * 2))
let outerY = center.y + radius * CGFloat(sin(degrees / 360 * Double.pi * 2))
path.move(to: CGPoint(x: innerX, y: innerY))
path.addLine(to: CGPoint(x: outerX, y: outerY))
}
return path
}
}
Related
I have 3 views that are each centered on consecutive iPhone screens. How do I calculate the offset to display "2" as center width in the preview?
struct debugView: View {
#State private var currentIndex = 0
#State private var offset = CGSize.zero
private let choices: [String] = [
"1",
"2",
"3",
]
var body: some View {
GeometryReader { geometry in
HStack {
AssessmentChoiceView(choice: choices[0]).position(x: (geometry.size.width / 2) + (CGFloat(0) * geometry.size.width), y: geometry.size.height / 2.3)
AssessmentChoiceView(choice: choices[1]).position(x: (geometry.size.width / 2) + (CGFloat(1) * geometry.size.width), y: geometry.size.height / 2.3)
AssessmentChoiceView(choice: choices[2]).position(x: (geometry.size.width / 2) + (CGFloat(2) * geometry.size.width), y: geometry.size.height / 2.3)
}
.offset(x: -(geometry.size.width / 2) - (CGFloat(1) * geometry.size.width))
}
}
}
I have an issue with the shadow shape not being animated at the same time as the discs.
Here's the corresponding code. You can test this on an iPad by changing the orientation.
This is a direct consequence of the ContentView. Removing the GeometryReader and using a fixed frame fixes it.
import SwiftUI
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
ZStack {
PlanetView()
.frame(width: geometry.size.width * 0.25, height: geometry.size.width * 0.25)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
struct PlanetView: View {
var body: some View {
GeometryReader { geometry in
ZStack {
Circle()
.fill(.blue)
Circle()
.foregroundColor(.green)
.overlay(PlanetShadowView())
.frame(width: geometry.size.width * 0.8, height: geometry.size.width * 0.8)
}
}
}
}
struct PlanetShadowView: View {
var offsetRatio: CGFloat = 15/100.0
func control1(_ size: CGSize) -> CGPoint {
let xOffset = offsetRatio * size.width
let yOffset = offsetRatio * size.height
return .init(x: xOffset, y: size.height - yOffset)
}
func control2(_ size: CGSize) -> CGPoint {
let xOffset = offsetRatio * size.width
let yOffset = offsetRatio * size.height
return .init(x: size.width - xOffset, y: size.height - yOffset)
}
var body: some View {
GeometryReader { geometry in
Path { path in
path.move(to: CGPoint(x: 0, y: geometry.size.height / 2))
path.addCurve(to: CGPoint(x: geometry.size.width, y: geometry.size.height / 2),
control1: control1(geometry.size),
control2: control2(geometry.size))
path.addArc(center: CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2),
radius: geometry.size.width / 2,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: 180),
clockwise: false)
path.closeSubpath()
}
.fill(Color.black.opacity(0.25))
.rotationEffect(.degrees(-30))
}
}
}
struct PlanetView_Previews: PreviewProvider {
static var previews: some View {
PlanetView()
}
}
A solution for this is to make planet shadow as a shape, so it would render in the same GeometryReader transformation context as everything else.
Tested with Xcode 13.4 / iOS 15.5
Circle()
.foregroundColor(.green)
.overlay(
PlanetShadow() // << here !!
.fill(Color.black.opacity(0.25))
.rotationEffect(.degrees(-30)))
.frame(width: geometry.size.width * 0.8, height: geometry.size.width * 0.8)
}
}
}
}
struct PlanetShadow: Shape { // << here !!
var offsetRatio: CGFloat = 15/100.0
func path(in rect: CGRect) -> Path {
// calc here ...
Test module on GitHub
Add .drawingGroup(). This should render the composition as a whole before displaying it.
I would like to apply an inner shadow on the stroke of a shape. I have found an function that handles the inner shadow (can't use iOS 16 yet), but I'm having a hard time trying to apply this function to the stroke itself.
Here's what I got:
struct ContentView: View {
var body: some View {
VStack {
PolygonShape(sides: 7)
.stroke(Color.white, lineWidth: 10)
PolygonShape(sides: 7)
.innerShadow(color: .red, radius: 10)
}
.padding(32)
.background(Color.black.ignoresSafeArea())
}
}
extension Shape {
func innerShadow(color: Color, radius: Double) -> some View {
overlay(
stroke(color, lineWidth: radius)
.blur(radius: radius)
.mask(self)
)
}
}
struct PolygonShape: Shape {
let sides: Int
func path(in rect: CGRect) -> Path {
let c = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
let r = Double(min(rect.width,rect.height)) / 2.0
var vertices:[CGPoint] = []
for i in 0...sides {
let angle = (2.0 * Double.pi * Double(i)/Double(sides))
let pt = CGPoint(x: r * cos(angle), y: r * sin(angle))
vertices.append(CGPoint(x: pt.x + c.x, y: pt.y + c.y))
}
var path = Path()
for (n, pt) in vertices.enumerated() {
n == 0 ? path.move(to: pt) : path.addLine(to: pt)
}
path.closeSubpath()
return path
}
}
struct StackedElementsView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
This is what it looks like but not what I want:
What I would like to do is put the inner red shadow just on the white stroke itself, so the stroke has red shadow in it only not in the shape.
Something similar to this:
I tried applying the inner shadow on the color itself but since it is not a shape it did not compile: .stroke(Color.white.innerShadow(color: .red, radius: 2)
How can I apply the inner shadow on a stroke color?
This isn't the cleanest or most complete answer, but it's simple.
The following code, in your var body...
ZStack {
PolygonShape(sides: 7)
.stroke(Color.white, lineWidth: 10)
PolygonShape(sides: 7)
.stroke(Color.black, lineWidth: 10)
.scaleEffect(1.07)
.shadow(color: .red, radius: 3)
PolygonShape(sides: 7)
.scaleEffect(0.965)
.shadow(color: .red, radius: 3)
}
...produces this type of border:
There's a red line around the shape, though, which may or may not be an easy fix, depending on your situation. Just throwing this as a temporary answer :) Alternatively, if you can get the shape to be just the border (with no fill), you could use .mask and mask it to a gradient of your choice.
Although not perfect and probably will not handle many cases, an alternate way is drawing the stroke, then another stroke half the size within in it that's blurred:
extension Shape {
func stroke<Stroke: ShapeStyle>(
_ content: Stroke,
lineWidth: Double = 1,
innerShadow: (color: Color, radius: Double)
) -> some View {
stroke(content, lineWidth: lineWidth / 2)
.blur(radius: innerShadow.radius)
.background(stroke(innerShadow.color, lineWidth: lineWidth))
}
}
PolygonShape(sides: 7)
.stroke(Color.white, lineWidth: 10, innerShadow: (Color.red, 2))
I have some problems to scale a Path accordingly to my purpose. I'm getting from an API some SVG data that I would like to draw in my view. In order to make it simple let's say this is what I get from the API:
extension UIBezierPath {
static var rectangle: UIBezierPath {
let path = UIBezierPath()
path.move(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 100, y: 300))
path.addLine(to: CGPoint(x: 300, y: 300))
path.addLine(to: CGPoint(x: 200, y: 100))
return path
}
}
When I display this path in my view everything works fine.
Since the SVG coordinates from the API have an offset and are sometimes bigger than the display screen of the iPhone, I need to scale them down/up to a specific size and center them in my view. In order to do that I tried this transformation:
struct ScaledShapeView: Shape {
let bezier: UIBezierPath
func path(in rect: CGRect) -> Path {
let bounds = bezier.bounds
let scaleX = rect.size.width/bounds.size.width
let scaleY = rect.size.height/bounds.size.height
let scale = max(scaleX, scaleY)
return Path(bezier.cgPath).applying(CGAffineTransform(scaleX: scale, y: scale))
}
}
and uses it in my view:
struct TestView: View {
var body: some View {
ScaledShapeView(bezier: .rectangle)
.frame(width: 100, height: 100)
}
}
But this is my result:
I'm not sure what exactly the problem is. I would like to achieve that the shape is in the middle of the frame.
#SwiftPunk:
When I try
var body: some View {
Path(UIBezierPath.logo1.cgPath)
.frame(width: 100, height: 100)
.scaleEffect(CGSize(width: 0.5, height: 0.5))
}
I get this result:
Normalized each X and Y position for drawing the points between 0 and 1, where 0 means the top edge or the leading edge, and 1 means the bottom edge or the trailing edge.
Also,we’re going to find the minimum of width and height so we can scale our Bezier path proportionally, so it stays the same shape as it grows.
import SwiftUI
extension UIBezierPath{
static var rectangle: UIBezierPath {
let path = UIBezierPath()
path.move(to: CGPoint(x: 0.5, y: 0.2))
path.addLine(to: CGPoint(x: 0.2, y: 0.8))
path.addLine(to: CGPoint(x: 0.8, y: 0.8))
path.addLine(to: CGPoint(x: 0.5, y: 0.2))
return path
}
}
struct ScaledShapeView:Shape{
var bezier:UIBezierPath
func path(in rect: CGRect) -> Path {
let path = Path(bezier.cgPath)
let multiplier = min(rect.width,rect.height)
let transform = CGAffineTransform(scaleX:multiplier , y: multiplier)
return path.applying(transform)
}
}
struct TestView: View {
#State private var endAmount: CGFloat = 0
var body: some View {
ScaledShapeView(bezier: UIBezierPath.rectangle)
.frame(width: 300, height: 300)
}
}
Output-:
Can someone please explain why my self made "myRectangle" that I have conformed to InsettableShape doesn't work with .strokeBorder but the built in Rectangle() does?
Here is myRectangle code;
struct myRectangle: InsettableShape {
var insetAmount: CGFloat = 0
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX * 1.2, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.midX * 1.2, y: rect.midY * 0.6))
path.addLine(to: CGPoint(x: rect.midX * 0.8, y: rect.midY * 0.6))
path.addLine(to: CGPoint(x: rect.midX * 0.8, y: rect.maxY))
return path
}
func inset(by amount: CGFloat) -> some InsettableShape {
var rectangle = self
rectangle.insetAmount -= amount
return rectangle
}
}
then modifying it with the .strokeBorder;
struct ColorCyclingRectangle: View {
var amount = 0.0
var steps = 100
var body: some View {
ZStack {
ForEach(0..<steps) { value in
myRectangle()
.inset(by: CGFloat(value))
.strokeBorder(self.color(for: value, brightness: 1), lineWidth: 2)
}
}
.drawingGroup()
}
func color(for value: Int, brightness: Double) -> Color {
var targetHue = Double(value) / Double(self.steps) + self.amount
if targetHue > 1 {
targetHue -= 1
}
return Color(hue: targetHue, saturation: 1, brightness: brightness)
}
}
I am expecting the rainbow effect but just get a gradient line.
I have tried modifying my path to - insetAmount but I just get a weird shape and the rainbow border is straight, not square
Am I misunderstanding somthing or does this modifyer only work on the default shapes?
Though this is an older questions I will make an attempt to answer it in hopes the OP is still interested or that it may help others.
You can most definitely use the strokeBorder() modifier on custom types that conform to the InsettableShape protocol noting that it is your responsibility to write the code that insets - reduces size - of your custom shape accordingly.
The first item I noticed in your code is that you are hardcoding some values within your path(in rect: CGRect) method which is why your output is not a square. Two options for this are:
Add width and height variables to myRectangle and use them in your path(in rect: CGRect) to create the shape
Create the shape using the entire size of rect and utilize the .frame() modifier on the container where you are creating your custom shape or on the shape directly to control its dimensions.
I am not sure which is preferable, but I will use option 2 as it seems this is how the built-in shapes work - in relation to width and height at least - and it makes it easier to comprehend the insetting code as follows
struct myRectangle: InsettableShape {
var insetAmount: CGFloat = 0
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + insetAmount, y: rect.maxY - insetAmount))
path.addLine(to: CGPoint(x: rect.maxX - insetAmount, y: rect.maxY - insetAmount))
path.addLine(to: CGPoint(x: rect.maxX - insetAmount, y: rect.minY + insetAmount))
path.addLine(to: CGPoint(x: rect.minX + insetAmount, y: rect.minY + insetAmount))
path.addLine(to: CGPoint(x: rect.minX + insetAmount, y: rect.maxY - insetAmount))
return path
}
func inset(by amount: CGFloat) -> some InsettableShape {
var rectangle = self
rectangle.insetAmount += amount
return rectangle
}
}
In the above you will note:
I removed the 1.2, 0.8 and 0.6 and instead utilized minX, maxX, minY and maxY to create the shape utilizing the entirety of rect size
To inset a rectangle you need to bring each of the edge points inwards by the inset amount taking account that SwiftUI measures coordinates from top-left, so you can say top-left would be (0,0)
I have used the insetAmount variable within the code in path(in rect: CGRect) that creates the path. For example path.move(to: CGPoint(x: rect.minX + insetAmount, y: rect.maxY - insetAmount)) is the bottom-left point. To inset this point you need to move the X to the right rect.minX + insetAmount and Y upwards rect.maxY - insetAmount - we are subtracting from Y to move upwards per my point 2.
In your ColorCyclingRectangle custom View I only added a .frame(width: 300, height: 300) modifier to the ZStack container that you are creating your custom shape in so that I can control its size.
struct ColorCyclingRectangle: View {
var amount = 0.0
var steps = 100
var body: some View {
ZStack {
ForEach(0..<steps) { value in
myRectangle()
.inset(by: CGFloat(value))
.strokeBorder(self.color(for: value, brightness: 1), lineWidth: 2)
}
}
.drawingGroup()
.frame(width: 300, height: 300)
}
func color(for value: Int, brightness: Double) -> Color {
var targetHue = Double(value) / Double(self.steps) + self.amount
if targetHue > 1 {
targetHue -= 1
}
return Color(hue: targetHue, saturation: 1, brightness: brightness)
}
}
With this I was able to get the rainbow effect.
Hopefully this resolves your issue.