Hit detection when drawing lines in iOS
Well, I did come up with a way to do this. It is imperfect, but I thought others might want to see the technique since this question was upvoted a few times. The technique I used draws all the items to be tested against into a bitmap context and then draws the new segment of the progressing line into another bitmap context. The data in those contexts is compared using bitwise operators and if any overlap is found, a hit is declared.
The idea behind this technique is to test each segment of a newly drawn line against all the previously drawn lines and even against earlier pieces of the same line. In other words, this technique will detect when a line crosses another line and also when it crosses over itself.
A sample app demonstrating the technique is available: LineSample.zip.
The core of hit testing is done in my LineView object. Here are two key methods:
- (CGContextRef)newBitmapContext {
// creating b&w bitmaps to do hit testing
// based on: http://robnapier.net/blog/clipping-cgrect-cgpath-531
// see "Supported Pixel Formats" in Quartz 2D Programming Guide
CGContextRef bitmapContext =
CGBitmapContextCreate(NULL, // data automatically allocated
self.bounds.size.width,
self.bounds.size.height,
8,
self.bounds.size.width,
NULL,
kCGImageAlphaOnly);
CGContextSetShouldAntialias(bitmapContext, NO);
// use CGBitmapContextGetData to get at this data
return bitmapContext;
}
- (BOOL)line:(Line *)line canExtendToPoint:(CGPoint) newPoint {
// Lines are made up of segments that go from node to node. If we want to test for self-crossing, then we can't just test the whole in progress line against the completed line, we actually have to test each segment since one segment of the in progress line may cross another segment of the same line (think of a loop in the line). We also have to avoid checking the first point of the new segment against the last point of the previous segment (which is the same point). Luckily, a line cannot curve back on itself in just one segment (think about it, it takes at least two segments to reach yourself again). This means that we can both test progressive segments and avoid false hits by NOT drawing the last segment of the line into the test! So we will put everything up to the last segment into the hitProgressLayer, we will put the new segment into the segmentLayer, and then we will test for overlap among those two and the hitTestLayer. Any point that is in all three layers will indicate a hit, otherwise we are OK.
if (line.failed) {
// shortcut in case a failed line is retested
return NO;
}
BOOL ok = YES; // thinking positively
// set up a context to hold the new segment and stroke it in
CGContextRef segmentContext = [self newBitmapContext];
CGContextSetLineWidth(segmentContext, 2); // bit thicker to facilitate hits
CGPoint lastPoint = [[[line nodes] lastObject] point];
CGContextMoveToPoint(segmentContext, lastPoint.x, lastPoint.y);
CGContextAddLineToPoint(segmentContext, newPoint.x, newPoint.y);
CGContextStrokePath(segmentContext);
// now we actually test
// based on code from benzado: http://stackoverflow.com/questions/6515885/how-to-do-comparisons-of-bitmaps-in-ios/6515999#6515999
unsigned char *completedData = CGBitmapContextGetData(hitCompletedContext);
unsigned char *progressData = CGBitmapContextGetData(hitProgressContext);
unsigned char *segmentData = CGBitmapContextGetData(segmentContext);
size_t bytesPerRow = CGBitmapContextGetBytesPerRow(segmentContext);
size_t height = CGBitmapContextGetHeight(segmentContext);
size_t len = bytesPerRow * height;
for (int i = 0; i < len; i++) {
if ((completedData[i] | progressData[i]) & segmentData[i]) {
ok = NO;
break;
}
}
CGContextRelease(segmentContext);
if (ok) {
// now that we know we are good to go,
// we will add the last segment onto the hitProgressLayer
int numberOfSegments = [[line nodes] count] - 1;
if (numberOfSegments > 0) {
// but only if there is a segment there!
CGPoint secondToLastPoint = [[[line nodes] objectAtIndex:numberOfSegments-1] point];
CGContextSetLineWidth(hitProgressContext, 1); // but thinner
CGContextMoveToPoint(hitProgressContext, secondToLastPoint.x, secondToLastPoint.y);
CGContextAddLineToPoint(hitProgressContext, lastPoint.x, lastPoint.y);
CGContextStrokePath(hitProgressContext);
}
} else {
line.failed = YES;
[linesFailed addObject:line];
}
return ok;
}
I'd love to hear suggestions or see improvements. For one thing, it would be a lot faster to only check the bounding rect of the new segment instead of the whole view.
How to do hit detection in core graphics
There are various approaches, depending on exactly what your end-goal is.
One approach:
- calculate the "degrees-per-slice" ... 360 / 20 = 18
- get the angle from the center point to the touch point
- "fix" the angle by 1/2 of the slice width (since the slices don't start at zero)
- divide that angle by degrees-per-slice to get the slice number
Use these two extensions to make it easy to get the angle (in degrees):
extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}
extension CGPoint {
func angle(to otherPoint: CGPoint) -> CGFloat {
let pX = otherPoint.x - x
let pY = otherPoint.y - y
let radians = atan2f(Float(pY), Float(pX))
var degrees = CGFloat(radians).degrees
while degrees < 0 {
degrees += 360
}
return degrees
}
}
And, in the code you posted, in your DrawTest
class, change didHit
to:
static func didHit(_ point: CGPoint, in bounds: CGRect){
let c: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let angle = c.angle(to: point)
var fixedAngle = Int(angle) + 99 // 90 degrees + 1/2 of slice width
if fixedAngle >= 360 {
fixedAngle -= 360
}
print("HIT:", fixedAngle / 18)
}
and include the bounds when you call it from DartBoardView
class as:
@objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
// include self's bounds
DrawTest.didHit(loc, in: bounds)
}
}
Drawbacks include:
- you'd also need to check the "line length" to make sure it doesn't extend outside the circle
- you don't have easy access to the slice bezier paths (if you want to do something else with them)
Another approach would be to use shape layers for each slice, making it easier to track the bezier paths.
Start with a Struct
for the slices:
struct Slice {
var color: UIColor = .white
var path: UIBezierPath = UIBezierPath()
var shapeLayer: CAShapeLayer = CAShapeLayer()
var key: Int = 0
}
The DartBoardView
class becomes (note: it uses the same CGFloat
extension from above):
extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}
class DartBoardView: UIView {
// array of slices
var slices: [Slice] = []
// slice width in degrees
let sliceWidth: CGFloat = 360.0 / 20.0
// easy to understand 12 o'clock (3 o'clock is Zero)
let twelveOClock: CGFloat = 270
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
let dark = UIColor(red: 0.235, green: 0.208, blue: 0.208, alpha: 1.000)
let light = UIColor(red: 0.435, green: 0.408, blue: 0.408, alpha: 1.000)
for slice in 0..<20 {
let sliceColor = slice % 2 == 1 ? dark : light
let s = Slice(color: sliceColor, key: slice)
s.shapeLayer.fillColor = s.color.cgColor
layer.addSublayer(s.shapeLayer)
slices.append(s)
}
let gesture = UITapGestureRecognizer(target: self, action: #selector(self.clickAction(sender:)))
addGestureRecognizer(gesture)
}
@objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
if let s = slices.first(where: { $0.path.contains(loc) }) {
print("HIT:", s.key)
} else {
print("Tapped outside the circle!")
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
let c: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let radius: CGFloat = bounds.midX
// slice width in radians
let ww: CGFloat = sliceWidth.radians
// start 1/2 sliceWidth less than 12 o'clock
var startDegrees: CGFloat = twelveOClock.radians - (ww * 0.5)
for i in 0..<slices.count {
let endDegrees: CGFloat = startDegrees + ww
let pth: UIBezierPath = UIBezierPath()
pth.addArc(withCenter: c, radius: radius, startAngle: startDegrees, endAngle: endDegrees, clockwise: true)
pth.addLine(to: c)
pth.close()
slices[i].path = pth
slices[i].shapeLayer.path = pth.cgPath
startDegrees = endDegrees
}
}
}
And here's an example controller class to demonstrate:
class DartBoardViewController: UIViewController {
let dartBoard = DartBoardView()
override func viewDidLoad() {
super.viewDidLoad()
dartBoard.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(dartBoard)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
dartBoard.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
dartBoard.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
dartBoard.heightAnchor.constraint(equalTo: dartBoard.widthAnchor),
dartBoard.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
dartBoard.backgroundColor = .black
}
}
Edit
Not as complex as it may seem.
Here's an implementation of a full Dart Board (without the numbers - I'll leave that as an exercise for you):
Segment Struct
struct Segment {
var value: Int = 0
var multiplier: Int = 1
var color: UIColor = .cyan
var path: UIBezierPath = UIBezierPath()
var layer: CAShapeLayer = CAShapeLayer()
}
DartBoardView class
class DartBoardView: UIView {
var doubleSegments: [Segment] = [Segment]()
var outerSingleSegments: [Segment] = [Segment]()
var tripleSegments: [Segment] = [Segment]()
var innerSingleSegments: [Segment] = [Segment]()
var singleBullSegment: Segment = Segment()
var doubleBullSegment: Segment = Segment()
var allSegments: [Segment] = [Segment]()
let boardLayer: CAShapeLayer = CAShapeLayer()
let darkColor: UIColor = UIColor(white: 0.1, alpha: 1.0)
let lightColor: UIColor = UIColor(red: 0.975, green: 0.9, blue: 0.8, alpha: 1.0)
let darkRedColor: UIColor = UIColor(red: 0.8, green: 0.1, blue: 0.1, alpha: 1.0)
let darkGreenColor: UIColor = UIColor(red: 0.0, green: 0.5, blue: 0.3, alpha: 1.0)
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
layer.addSublayer(boardLayer)
boardLayer.fillColor = UIColor.black.cgColor
// points starting at 3 o'clock
let values: [Int] = [
6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5, 20, 1, 18, 4, 13,
]
// local vars for reuse
var seg: Segment = Segment()
var c: UIColor = .white
// doubles and triples
for i in 0..<values.count {
c = i % 2 == 1 ? darkRedColor : darkGreenColor
seg = Segment(value: values[i],
multiplier: 2,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
doubleSegments.append(seg)
seg = Segment(value: values[i],
multiplier: 3,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
tripleSegments.append(seg)
}
// singles
for i in 0..<values.count {
c = i % 2 == 1 ? darkColor : lightColor
seg = Segment(value: values[i],
multiplier: 1,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
outerSingleSegments.append(seg)
seg = Segment(value: values[i],
multiplier: 1,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
innerSingleSegments.append(seg)
}
// bull and double bull
seg = Segment(value: 25,
multiplier: 1,
color: darkGreenColor,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
singleBullSegment = seg
seg = Segment(value: 25,
multiplier: 2,
color: darkRedColor,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
doubleBullSegment = seg
let gesture = UITapGestureRecognizer(target: self, action: #selector(self.clickAction(sender:)))
addGestureRecognizer(gesture)
}
@objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
if let s = allSegments.first(where: { $0.path.contains(loc) }) {
print("HIT:", s.multiplier == 3 ? "Triple" : s.multiplier == 2 ? "Double" : "Single", s.value)
} else {
print("Tapped outside!")
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
// initialize local variables for reuse / readability
var startAngle: CGFloat = 0
var outerDoubleRadius: CGFloat = 0.0
var innerDoubleRadius: CGFloat = 0.0
var outerTripleRadius: CGFloat = 0.0
var innerTripleRadius: CGFloat = 0.0
var outerBullRadius: CGFloat = 0.0
var innerBullRadius: CGFloat = 0.0
// initialize local constants
let viewCenter: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
// leave 20% for the numbers area
let diameter = bounds.width * 0.8
// dart board radii in mm
let specRadii: [CGFloat] = [
170, 162, 107, 99, 16, 6
]
// convert to view size
let factor: CGFloat = (diameter * 0.5) / specRadii[0]
outerDoubleRadius = specRadii[0] * factor
innerDoubleRadius = specRadii[1] * factor
outerTripleRadius = specRadii[2] * factor
innerTripleRadius = specRadii[3] * factor
outerBullRadius = specRadii[4] * factor
innerBullRadius = specRadii[5] * factor
let wireColor: UIColor = UIColor(white: 0.8, alpha: 1.0)
let wedgeWidth: CGFloat = 360.0 / 20.0
let incAngle: CGFloat = wedgeWidth.radians
startAngle = -(incAngle * 0.5)
var path: UIBezierPath = UIBezierPath()
// outer board layer
path = UIBezierPath(ovalIn: bounds)
boardLayer.path = path.cgPath
for i in 0..<20 {
let endAngle = startAngle + incAngle
var shape = doubleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: outerDoubleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: innerDoubleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath
doubleSegments[i].path = path
shape.fillColor = doubleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
shape = outerSingleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: innerDoubleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: outerTripleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath
outerSingleSegments[i].path = path
shape.fillColor = outerSingleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
shape = tripleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: outerTripleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: innerTripleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath
tripleSegments[i].path = path
shape.fillColor = tripleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
shape = innerSingleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: innerTripleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: outerBullRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath
innerSingleSegments[i].path = path
shape.fillColor = innerSingleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
startAngle = endAngle
}
let singleBullPath = UIBezierPath(ovalIn: CGRect(x: viewCenter.x - outerBullRadius, y: viewCenter.y - outerBullRadius, width: outerBullRadius * 2, height: outerBullRadius * 2))
let doubleBullPath = UIBezierPath(ovalIn: CGRect(x: viewCenter.x - innerBullRadius, y: viewCenter.y - innerBullRadius, width: innerBullRadius * 2, height: innerBullRadius * 2))
var shape = singleBullSegment.layer
singleBullPath.append(doubleBullPath)
singleBullPath.usesEvenOddFillRule = true
shape.fillRule = .evenOdd
shape.path = singleBullPath.cgPath
singleBullSegment.path = singleBullPath
shape.fillColor = singleBullSegment.color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
shape = doubleBullSegment.layer
shape.path = doubleBullPath.cgPath
doubleBullSegment.path = doubleBullPath
shape.fillColor = doubleBullSegment.color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor
// append all segments for hit-testing
allSegments = []
allSegments.append(contentsOf: tripleSegments)
allSegments.append(contentsOf: outerSingleSegments)
allSegments.append(contentsOf: doubleSegments)
allSegments.append(contentsOf: innerSingleSegments)
allSegments.append(singleBullSegment)
allSegments.append(doubleBullSegment)
}
}
CGFloat extension
extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}
Example view controller
class DartBoardViewController: UIViewController {
let dartBoard = DartBoardView()
override func viewDidLoad() {
super.viewDidLoad()
dartBoard.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(dartBoard)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
dartBoard.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
dartBoard.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
dartBoard.heightAnchor.constraint(equalTo: dartBoard.widthAnchor),
dartBoard.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
dartBoard.backgroundColor = .clear
}
}
Result:
and debug output from a few taps:
HIT: Double 20
HIT: Single 18
HIT: Triple 2
HIT: Single 25
HIT: Double 25
Spritkit : Detect collision while drawing line using finger
you are creating your physics body with an edge loop. Apple defines edge loops as...
An edge has no volume or mass and is always treated as if the isDynamic property is equal to false. Edges may only collide with volume-based physics bodies.
changing your physics body to this works
currentLineNode.physicsBody = SKPhysicsBody(rectangleOf: currentLineNode.frame.size, center: CGPoint(x: startPoint.x + currentLineNode.frame.size.width / 2, y: startPoint.y + currentLineNode.frame.size.height / 2))
also should be noted that changing your physics body in touchesEnded is redundant and adds nothing. I removed it from touchesEnded and it works fine
Drawing line with SpriteKit and detecting collision
Unfortunately nodes in SpriteKit cannot be concave (http://mathworld.wolfram.com/ConcavePolygon.html), with lines that cross over each other.
You will have to create separate nodes for each lines between adjacent points and then possibly join them together. you could do this either by:
- Create the 1st segment and then add subsequent segments as children
of that 1st segment. - Create a non-visible node for the whole line and add wll the line
segments as children of that node. - Create physicsbodies for each segment and join them with
SKPhysicsJointFixed
.
How can I collide line with line itself?
In the example code, everything is just rendered on the screen. No part of a collision detection system is implemented.
For implementing one of those, one of the simplest ways is to put all the points that make up the line in an NSMutableArray, and every time you want to draw a new point, you can check it against all the points contained in the array. If the new point is already contained in the array, then you have a collision between the line and the new point you are trying to draw.
From there on, you can research standard collision systems, and implement one of those.
Cocos2D also supports 2 physics engines: Box2D and Chipmunk, both of which have collision detection of their own. For efficiency, you might want to use one of those instead of implementing your own system.
Drawing a line through finger touch on iPhone
I am using this:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
self.currentPath = [UIBezierPath bezierPath];
currentPath.lineWidth = 3.0;
[currentPath moveToPoint:[touch locationInView:self]];
[paths addObject:self.currentPath];
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
[self.currentPath addLineToPoint:[touch locationInView:self]];
[self setNeedsDisplay];
}
- (void)drawRect:(CGRect)rect {
[[UIColor redColor] set];
for (UIBezierPath *path in paths) {
[path stroke];
}
}
You can get related class reference from apple.
Related Topics
Does iPhone Support Hardware-Accelerated Aes Encryption
Universal Link Broken in iOS 11.2
Cameranode Rotate as iOS Device Moving
Uibutton with Single Press and Long Press Events Swift
Ignore Manual Entries from Apple Health App as Data Source
How to Mute/Unmute Audio When Playing Video Using Mpmovieplayercontroller
Firestore Paginating Data + Snapshot Listener
Detect Hotspot Enabling in iOS with Private API'S
Stroke Width with a Scenekit Line Primitive Type
How to Parse Array of JSON to Array in Swift
Itms-90809: Deprecated API Usage - Existing App That Use Uiwebview Are No Longer Accepted
How to Control Avassetwriter to Write at the Correct Fps
Using the C API for Imagemagick (On Iphone) to Convert to Monochrome
Open App from Sms with My Url Scheme as a Link