Custom shape registers taps outside drawn path - swiftui

I created a custom Arc shape in SwiftUI, but when I add an onTapGesture modifier to it, it registers taps outside the area I drew for the Arc (it registers taps anywhere within the rect used to draw the Arc). How can I make sure the taps only register within the drawn path of the Arc, not the entire rect?
Here's the Arc shape I drew:
struct Arc: Shape {
let angle: Angle
let thickness: CGFloat
let clockwise: Bool
func path(in rect: CGRect) -> Path {
var path = Path()
let innerArcEndPoint = CGPoint(x: rect.maxX - thickness, y: 0)
let innerArcStartPoint = CGPoint(
x: innerArcEndPoint.x * cos(CGFloat(angle.radians)),
y: innerArcEndPoint.x * sin(CGFloat(angle.radians)))
path.move(to: innerArcStartPoint)
path.addArc(center: rect.origin, radius: innerArcEndPoint.x, startAngle: angle, endAngle: Angle.zero, clockwise: !clockwise)
path.addLine(to: CGPoint(x: rect.maxX, y: 0))
path.addArc(center: rect.origin, radius: rect.maxX, startAngle: Angle.zero, endAngle: angle, clockwise: clockwise)
path.closeSubpath()
return path
}
}
Here's me using the Arc with an onTapGesture modifier:
struct TestTappingArc: View {
var body: some View {
Arc(angle: Angle(degrees: 45), thickness: 20, clockwise: false)
.foregroundColor(.blue) // The only area I want to be tappable
.background(Color.orange) // The area I DON'T want to be tappable (but currently is tappable)
.frame(width: 100, height: 100)
.onTapGesture {
print("hello")
}
}
}
And here's what it looks like on screen:

You need to use contentShape with same shape to restrict hit-testing area (because view is always rectangular), like
Arc(angle: Angle(degrees: 45), thickness: 20, clockwise: false)
.foregroundColor(.blue)
.contentShape(Arc(angle: Angle(degrees: 45), thickness: 20, clockwise: false))
.onTapGesture { // << here !!
print("hello")
}
.background(Color.orange)
.frame(width: 100, height: 100)

Thanks Asperi for that answer--that does fix the problem. However, I realized an even simpler solution is just to move the background to after the onTapGesture modifier, like this:
Arc(angle: Angle(degrees: 45), thickness: 20, clockwise: false)
.foregroundColor(.blue)
.frame(width: 100, height: 100)
.onTapGesture {
print("hello")
}
.background(Color.orange)

Related

How to position custom shape in a center of a button

I've found an animated custom Tab bar tutorial and tried to reproduce it. But now I'm stuck on the moment where I need to place the center of my custom shape in the center of the button every time it is pressed. Would be very appreciate if somebody could explain to me why does midX is located between buttons?
Here is expecting result :
And here is what I get when taping on the button with code below:
Code:
struct TabBar: View{
#State private var selectedTab: Tabs.RawValue = Tabs.home.rawValue
#State private var xAxisForCurve: CGFloat = 80
let screenWidth: CGFloat
private var tabs : [String]{
var _tabs: [String] = []
Tabs.allCases.forEach { tab in
_tabs.append(tab.rawValue)
}
return _tabs
}
var body: some View{
HStack{
ForEach(tabs, id: \.self) { tab in
GeometryReader { button in
Button(action: {
withAnimation(.spring()){
self.selectedTab = tab
self.xAxisForCurve = button.frame(in: .global).midX
}
}) {
Image(systemName: tab)
.resizable()
.renderingMode(.template)
.aspectRatio(contentMode: .fit)
.foregroundColor(self.selectedTab == tab ? Color.blue : Color.gray)
}
}
.frame(width: 28, height: 28)
if tab != tabs.last{
Spacer().frame(width: 65)
}
}
}
.frame(width: screenWidth * multiplayerForTabFrameOnDifferentDevices())
.padding(.vertical)
.background(Color.white.clipShape(MyShape(xAxis: self.xAxisForCurve)))
.cornerRadius(20)
}
}
struct MyShape: Shape{
var xAxis: CGFloat
func path(in rect: CGRect) -> Path {
Path{ path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: rect.width, y: 0))
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
path.addLine(to: CGPoint(x: 0, y: rect.height))
let upperLeftCorner = CGPoint(x: self.xAxis - 50 , y: rect.minY)
let upperRightCorner = CGPoint(x: self.xAxis + 50, y: rect.minY)
let curveCenter = CGPoint(x: self.xAxis, y: rect.height * 0.55)
let control1 = CGPoint(x: self.xAxis - 25 , y: 0)
let control2 = CGPoint(x: self.xAxis - 25 , y: 35)
let control4 = CGPoint(x: self.xAxis + 25 , y: 0)
let control3 = CGPoint(x: self.xAxis + 25 , y: 35)
path.move(to: upperLeftCorner)
path.addCurve(to: curveCenter, control1: control1, control2: control2)
path.addCurve(to: upperRightCorner, control1: control3, control2: control4)
}
}
}
You're taking global frame with frame(in: .global): it's frame related to the device screen.
But your shape needs position related to your TabBar bounds.
You can solve it by adding a named coordinate space:
.frame(width: screenWidth * multiplayerForTabFrameOnDifferentDevices())
.coordinateSpace(name: "container")
And getting button frame in this coordinate space:
xAxisForCurve = button.frame(in: .named("container")).midX
p.s. from your code it looks like you're trying to animate your shape. You need to add the following lines to your shape to make it animatable. More info can be found here
var animatableData: CGFloat {
get { xAxis }
set { xAxis = newValue }
}

Clip image with semi-circle mask?

I'm trying to add a circle background for an image, then clip the image using a semi-circle of the background. This is what I'm trying to end up with:
In SwiftUI, I put together something but stuck on what clipShape should I use:
Circle()
.fill(Color.blue)
.aspectRatio(contentMode: .fit)
.frame(width: 48)
.padding(.top, 8)
.overlay(
Image("product-sample1")
.resizable()
.scaledToFit()
//.clipShape(???)
)
This is what it looks like:
I'm not sure how to achieve this but was given a clue to use this approach:
How can this be achieved in SwiftUI, or is there a better approach to doing this?
You can create own shape. Below is a simple demo variant (for square content).
private struct DemoClipShape: Shape {
func path(in rect: CGRect) -> Path {
Path {
$0.move(to: CGPoint.zero)
$0.addLine(to: CGPoint(x: rect.maxX, y: 0))
$0.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
$0.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.midY, startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 90), clockwise: false)
$0.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.midY, startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 180), clockwise: false)
$0.addLine(to: CGPoint.zero)
}
}
}
And here is a sample of how it could be applied - due to padding we need to offset clipping (mask) to compensate locations (also it can be passed as constant into shape constructor, which will be more correct and accurate, but I leave it for you):
// used larger values for better visibility
Circle()
.fill(Color.blue)
.frame(width: 148, height: 148)
.padding(.top, 18)
.padding(.bottom, 10)
.overlay(
Image("product-sample1")
.resizable()
.scaledToFit()
)
.mask(DemoClipShape().offset(x: 0, y: -11)) // << here !!
gives

How to scale Path in SwiftUI?

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-:

How we can draw a stroke for a custom path in SwiftUI?

I am making a custom path in SwiftUI and fill it with red color, I want to give a border or stroke for my path. How we can do this?
struct ContentView: View
{
var body: some View
{
Path { path in
path.addArc(center: CGPoint(x: 100, y: 300), radius: 200, startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 0), clockwise: false)
}
.fill(Color.red)
}
}
You'll need to stroke and fill the path separately. Here is one way to do it.
Assign your path to a variable, and then use that to fill it, and then overlay it with the same path stroked. Note: You need to use path.closeSubpath() to close your path, or only the arc will be stroked.
struct ContentView: View
{
var body: some View {
let path = Path { path in
path.addArc(center: CGPoint(x: 100, y: 300), radius: 200, startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 0), clockwise: false)
path.closeSubpath()
}
path.fill(Color.red).overlay(path.stroke(Color.black, lineWidth: 2))
}
}
First start by declaring a custom Shape. You only have to implement func path(in rect: CGRect) -> Path :
struct Diamond: Shape {
func path(in rect: CGRect) -> Path {
let topMid = CGPoint(x: rect.midX, y: rect.minY)
let trailingMid = CGPoint(x: rect.maxX, y: rect.midY)
let bottomMid = CGPoint(x: rect.midX, y: rect.maxY)
let leadingMid = CGPoint(x: rect.minX, y: rect.midY)
var path = Path()
path.move(to: topMid)
path.addLine(to: trailingMid)
path.addLine(to: bottomMid)
path.addLine(to: leadingMid)
path.addLine(to: topMid)
return path
}
}
and then you can place in a ZStack: your background clipped to your custom shape and your custom shape with stroke applied:
struct ContentView: View {
var body: some View {
ZStack {
Color.blue
.clipShape(Diamond())
Diamond()
.stroke(Color.purple, lineWidth: 4)
} .padding()
}
}

How do I color a SwiftUI Rectangle two different colors?

Supposing I have a simple SwiftUI Rectangle such as:
Rectangle()
.frame(width: 20, height: 20)
How can I color one half of it white at a 45 degree angle, and the other half black at a 45 degree angle?
My initial guess was to layer two Rectangles over each other with ZStack, but that way seems hacky to me.
Simple geometry can be achieved by transforming the shape:
Rectangle()
.rotation(.degrees(45), anchor: .bottomLeading)
.scale(sqrt(2), anchor: .bottomLeading)
.frame(width: 200, height: 200)
.background(Color.red)
.foregroundColor(Color.blue)
.clipped()
The least "hacky" way could be Gradients. With a code like this:
Rectangle()
.fill(
LinearGradient(
gradient: Gradient(stops: [
Gradient.Stop(color: .red, location: 0.5),
Gradient.Stop(color: .black, location: 0.5)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing))
.frame(width: 200, height: 200)
You get a rectangle like this:
What actually happens is, that SwiftUI creates two color-gradients: One from 0.0 - 0.5, from red to red and a second gradient from 0.5 to 1.0 from black to black.
Also note, that the fill must immediately follow the path (here Rectangle) as it is the Shape variant of fill.
You should use path for drawing this. My idea is to put Rectangle and Triangle into ZStack:
struct GradientRectangle: View {
var body: some View {
ZStack {
Rectangle()
Triangle()
.foregroundColor(.red) // here should be .white in your case
}
.frame(width: 300, height: 300)
}
}
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
return path
}
}
the result will be: