Implement ink annotations on iOS 11 PDFKit document - ios

I want to allow the user to draw on an iOS 11 PDFKit document viewed in a PDFView. The drawing should ultimately be embedded inside the PDF.
The latter I have solved by adding a PDFAnnotation of type "ink" to the PDFPage with a UIBezierPath corresponding to the user's drawing.
However, how do I actually record the touches the user makes on top of the PDFView to create such an UIBezierPath?
I have tried overriding touchesBegan on the PDFView and on the PDFPage, but it is never called. I have tried adding a UIGestureRecognizer, but didn't accomplish anything.
I'm assuming that I need to afterwards use the PDFView instance method convert(_ point: CGPoint, to page: PDFPage) to convert the coordinates obtained to PDF coordinates suitable for the annotation.

In the end I solved the problem by creating a PDFViewController class extending UIViewController and UIGestureRecognizerDelegate. I added a PDFView as a subview, and a UIBarButtonItem to the navigationItem, that serves to toggle annotation mode.
I record the touches in a UIBezierPath called signingPath, and have the current annotation in currentAnnotation of type PDFAnnotation using the following code:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first {
let position = touch.location(in: pdfView)
signingPath = UIBezierPath()
signingPath.move(to: pdfView.convert(position, to: pdfView.page(for: position, nearest: true)!))
annotationAdded = false
UIGraphicsBeginImageContext(CGSize(width: 800, height: 600))
lastPoint = pdfView.convert(position, to: pdfView.page(for: position, nearest: true)!)
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first {
let position = touch.location(in: pdfView)
let convertedPoint = pdfView.convert(position, to: pdfView.page(for: position, nearest: true)!)
let page = pdfView.page(for: position, nearest: true)!
signingPath.addLine(to: convertedPoint)
let rect = signingPath.bounds
if( annotationAdded ) {
pdfView.document?.page(at: 0)?.removeAnnotation(currentAnnotation)
currentAnnotation = PDFAnnotation(bounds: rect, forType: .ink, withProperties: nil)
var signingPathCentered = UIBezierPath()
signingPathCentered.cgPath = signingPath.cgPath
signingPathCentered.moveCenter(to: rect.center)
currentAnnotation.add(signingPathCentered)
pdfView.document?.page(at: 0)?.addAnnotation(currentAnnotation)
} else {
lastPoint = pdfView.convert(position, to: pdfView.page(for: position, nearest: true)!)
annotationAdded = true
currentAnnotation = PDFAnnotation(bounds: rect, forType: .ink, withProperties: nil)
currentAnnotation.add(signingPath)
pdfView.document?.page(at: 0)?.addAnnotation(currentAnnotation)
}
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first {
let position = touch.location(in: pdfView)
signingPath.addLine(to: pdfView.convert(position, to: pdfView.page(for: position, nearest: true)!))
pdfView.document?.page(at: 0)?.removeAnnotation(currentAnnotation)
let rect = signingPath.bounds
let annotation = PDFAnnotation(bounds: rect, forType: .ink, withProperties: nil)
annotation.color = UIColor(hex: 0x284283)
signingPath.moveCenter(to: rect.center)
annotation.add(signingPath)
pdfView.document?.page(at: 0)?.addAnnotation(annotation)
}
}
The annotation toggle button just runs:
pdfView.isUserInteractionEnabled = !pdfView.isUserInteractionEnabled
This was really the key to it, as this disables scrolling on the PDF and enables me to receive the touch events.
The way the touch events are recorded and converted into PDFAnnotation immediately means that the annotation is visible while writing on the PDF, and that it is finally recorded into the correct position in the PDF - no matter the scroll position.
Making sure it ends up on the right page is just a matter of similarly changing the hardcoded 0 for page number to the pdfView.page(for: position, nearest:true) value.

I've done this by creating a new view class (eg Annotate View) and putting on top of the PDFView when the user is annotating.
This view uses it's default touchesBegan/touchesMoved/touchesEnded methods to create a bezier path following the gesture. Once the touch has ended, my view then saves it as an annotation on the pdf.
Note: you would need a way for the user to decide if they were in an annotating state.
For my main class
class MyViewController : UIViewController, PDFViewDelegate, VCDelegate {
var pdfView: PDFView?
var touchView: AnnotateView?
override func loadView() {
touchView = AnnotateView(frame: CGRect(x: 0, y: 0, width: 375, height: 600))
touchView?.backgroundColor = .clear
touchView?.delegate = self
view.addSubview(touchView!)
}
func addAnnotation(_ annotation: PDFAnnotation) {
print("Anotation added")
pdfView?.document?.page(at: 0)?.addAnnotation(annotation)
}
}
My annotation view
class AnnotateView: UIView {
var path: UIBezierPath?
var delegate: VCDelegate?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Initialize a new path for the user gesture
path = UIBezierPath()
path?.lineWidth = 4.0
var touch: UITouch = touches.first!
path?.move(to: touch.location(in: self))
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
// Add new points to the path
let touch: UITouch = touches.first!
path?.addLine(to: touch.location(in: self))
self.setNeedsDisplay()
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = touches.first
path?.addLine(to: touch!.location(in: self))
self.setNeedsDisplay()
let annotation = PDFAnnotation(bounds: self.bounds, forType: .ink, withProperties: nil)
annotation.add(self.path!)
delegate?.addAnnotation(annotation)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
self.touchesEnded(touches, with: event)
}
override func draw(_ rect: CGRect) {
// Draw the path
path?.stroke()
}
override init(frame: CGRect) {
super.init(frame: frame)
self.isMultipleTouchEnabled = false
}
}

EDIT:
jksoegaard's answer, while being the inspiration to all of my work on this matter, has a flaw: during touchedMoved, multiple PDF annotations are created, and consequently the PDF page becomes bogged down with annotations, which affects its loading time severely. I wrote code that draws on a CAShapeLayer during the touchedMoved phase, and creates the completed PDF annotation only in the touchesEnded phase.
My implementation is a subclass of UIGestureRecognizer, and it allows you to choose between pen, highlighter and eraser, and choose color and width. It also includes an undo manager. The example project is here.
Original Answer
Adding to jksoegaard's excellent answer, a few clarifications for newbies like myself:
You need to include UIBezierPath+.swift in your project for .moveCenter and rect.center to be recognized. Download from https://github.com/xhamr/paintcode-path-scale.
These lines can be excluded:
UIGraphicsBeginImageContext(CGSize(width: 800, height: 600))
and
let page = pdfView.page(for: position, nearest: true)!
You need to declare a few global vars outside the functions:
var signingPath = UIBezierPath()
var annotationAdded : Bool?
var lastPoint : CGPoint?
var currentAnnotation : PDFAnnotation?
Finally, if you want the ink to be wider and nicely colored, you need to do two things:
a. Every time before you see annotation.add or currentAnnotation.add, you need (use annotation or currentAnnotation as needed by that function):
let b = PDFBorder()
b.lineWidth = { choose a pixel number here }
currentAnnotation?.border = b
currentAnnotation?.color=UIColor.{ your color of choosing }
I recommend specifying a low alpha for the color. The result is beautiful, and affected by the speed of your stroke. For example, red would be:
UIColor(red: 255/255.0, green: 0/255.0, blue: 0/255.0, alpha: 0.1)
b. The rect in which every touch is recorded needs to accommodate for the thicker lines. Instead of
let rect = signingPath.bounds
Try, for an example of 10px of thickness:
let rect = CGRect(x:signingPath.bounds.minX-5,
y:signingPath.bounds.minY-5, width:signingPath.bounds.maxX-
signingPath.bounds.minX+10, height:signingPath.bounds.maxY-
signingPath.bounds.minY+10)
IMPORTANT: The touchesEnded function also makes use of the currentAnnotation variable. You must repeat the definition of rect within that function as well (either the short one or my suggested one above), and repeat the definition of currentAnnotation there as well:
currentAnnotation = PDFAnnotation(bounds: rect, forType: .ink, withProperties: nil)
If you don't, a single tap that did not move will make your app crash.
I can verify that once the file is saved, the annotations are retained. Sample code for saving:
let pdfData = pdfDocument?.dataRepresentation()
let annotatedPdfUrl = URL(fileURLWithPath: "\
(NSSearchPathForDirectoriesInDomains(.documentsDirectory, .userDomainMask,
true)[0])/AnnotatedPDF.pdf")
try! pdfData!.write(to: annotatedPdfUrl)

Related

Swift: Unable to touch canvas view on scrollView

I am attempting to build an image editing function in my app. I followed this tutorial to enable zooming capability and this tutorial for the drawing portion.
I am implementing the drawing portion and realised that the canvas view is not detecting the touches when it is overlaid onto a scrollView. The current hierarchy is as such:
scrollView -> imageView -> canvas
Implementation so far:
//At the viewController that implements the Canvas UIView
var imageView: UIImageView!
var canvas = Canvas()
override func viewDidLoad() {
super.viewDidLoad()
let image = UIImage(named: "testImage")
imageView = UIImageView(image: image)
setupCanvas()
}
func setupCanvas() {
imageView.addSubview(canvas)
canvas.backgroundColor = UIColor(white: 1, alpha: 0.4)
canvas.frame = CGRect(x: 0, y: 0, width: 3000, height: 3000)
canvas.isUserInteractionEnabled = true
}
//At the Canvas class
class Canvas: UIView {
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let point = touches.first?.location(in: self) else {return}
print("Moved", point) //Breakpoint here not called
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let point = touches.first?.location(in: self) else {return}
print("Began", point) //Breakpoint here not called
}
}
At this moment, the touches seems to be interacting with the scrollViewinstead. Is there a way that detect the touches on the canvas view instead?
Ok, elementary mistake. It should be
scrollView.addSubview(canvas)
instead.

Swift drawing app - changing line width value based on a slider

I'm trying to change the line width of my drawing app using a slider but every time I change the slider and redraw a line, all lines on the screen change to the currently selected line width. I must be doing something wrong.
var layers:[Array<CGPoint>] = []
var layerIndex = 0
var sliderValue: CGFloat = 3.0
var strokeInfo:[[String:Any]] = []
//change the slider
func slider(value: CGFloat) {
sliderValue = value
print("Slider value is \(sliderValue)")
}
//on new touch, start a new array (layer) of points
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
var points:[CGPoint] = []
layers.append(points)
var info = Dictionary<String,Any>()
info["line"] = sliderValue
strokeInfo.insert(info, at: layerIndex)
let pt = (touches.first!).location(in: self)
points.append(pt)
}
//add each point to the correct array as the finger moves
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let pt = (touches.first!).location(in: self)
layers[layerIndex].append(pt)
self.setNeedsDisplay()
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
print("layer \(layerIndex) now has \(layers[layerIndex].count)")
layerIndex += 1
}
override func draw(_ rect: CGRect) {
//get pointer to view
let context = UIGraphicsGetCurrentContext()
context?.clear(rect)
for (index, element) in strokeInfo.enumerated() {
if index == layerIndex {
context?.setLineWidth(element["line"] as! CGFloat)
}
}
//loop through the layers if any
for points in layers {
UIColor.cyan.set()
//loop through each layer's point values
if points.count - 1 > 0 {
for i in 0 ..< points.count-1 {
let pt1 = points[i] as CGPoint
let pt2 = points[i + 1] as CGPoint
context?.setLineCap(.round)
context?.move(to: pt1)
context?.addLine(to: pt2)
context?.strokePath()
}
}
}
}
You're changing the line width and line cap of the context. The settings of the graphics context affect the entire path associated with the context.
If you want to draw different paths, I suggest you use multiple UIBezierPath objects, each with it's own width, color, and line cap settings. You can draw your bezier paths in your drawRect method.
Alternately you could use multiple CAShapeLayers, each with different path drawing settings and stack them on top of each other to get the composite image you want.

How to drag images

I can move a single UIView using the code below but how do I move multiple UIView's individually using IBOutletCollection and tag values?
class TeamSelection: UIViewController {
var location = CGPoint(x: 0, y: 0)
#IBOutlet weak var ViewTest: UIView! // move a single image
#IBOutlet var Player: [UIView]! // collection to enable different images with only one outlet
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch: UITouch = touches.first! as UITouch
location = touch.location(in: self.view)
ViewTest.center = location
}
}
There are two basic approaches:
You could iterate through your subviews figuring out in which one the touch intersected and move it. But this approach (nor the use of cryptic tag numeric values to identify the view) is generally not the preferred method.
Personally, I'd put the drag logic in the subview itself:
class CustomView: UIView { // or subclass `UIImageView`, as needed
private var originalCenter: CGPoint?
private var dragStart: CGPoint?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
originalCenter = center
dragStart = touches.first!.location(in: superview)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
var location = touch.location(in: superview)
if let predicted = event?.predictedTouches(for: touch)?.last {
location = predicted.location(in: superview)
}
center = dragStart! + location - originalCenter!
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: superview)
center = dragStart! + location - originalCenter!
}
}
extension CGPoint {
static func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}
static func -(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
}
}
Remember to set "user interaction enabled" for the subviews if you use this approach.
By the way, if you're dragging views like this, make sure you don't have constraints on those views or else when the auto-layout engine next applies itself, everything will move back to the original location. If using auto-layout, you'd generally modify the constant of the constraint.
A couple of observations on that dragging logic:
You might want to use predictive touches, like above, to reduce lagginess of the drag.
Rather than moving the center to the location(in:) of the touch, I would rather keep track of by how much I dragged it and either move the center accordingly or apply a corresponding translation. It's a nicer UX, IMHO, because if you grab the the corner, it lets you drag by the corner rather than having it jump the center of the view to where the touch was on screen.
I'd create a subclass of UIView which supports dragging. Something like:
class DraggableView: UIView {
func setDragGesture() {
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(DraggableView.handlePanGesture(_:)))
addGestureRecognizer(panRecognizer)
}
func handlePanGesture(_ recognizer: UIPanGestureRecognizer) {
guard let parentView = self.superview else { return }
let translation = recognizer.translation(in: parentView)
recognizer.view?.center = CGPoint(x: recognizer.view!.center.x + translation.x, y: recognizer.view!.center.y + translation.y)
recognizer.setTranslation(CGPoint.zero, in: self)
}
func getLocation() -> CGPoint {
return UIView().convert(center, to: self.superview)
}
}
So then you can add an array of draggable views and then ask for the location when you need to finish displaying that view controller.

Is it possible to cancel a touch from inside touchesBegan or touchesMoved?

In my main view in a UIViewController I have a mapView and a another view (Let's say view A) that is above mapView. Both of them have frames equal to self.view.bounds. The view A is a rectangle that is resizable similar to those used to crop images. My goal here is to let the user specify an area on the map. So, I want the user to be able to zoom in an out of the map as well as change the rectangle width and height proportions since only letting the view A to be an unrealizable square would limit it too much.
I got this project from GitHub https://github.com/justwudi/WDImagePicker from which I am using the resizable rectangle functionality. In the second picture of the Github link, there's a rectangle with 8 dots and a shaded area outside. I want to be able to let the touch pass to the map which is behind the view A if the user touches on the shaded area. Only if the user clicks on the area inside the dots or on the dots (so that he wants to resize the rectangle) I want the view A to recognize the touch. So, I modified the code on touch on the view A and have this:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first {
if cropBorderView.frame.contains(touch.location(in: self)){
print("touch contains - touchesbegan")
//self.isUserInteractionEnabled = true
}
else{
print("Touch does not contain - touchesbegan")
self.touchesCancelled(touches, with: event)
//return
}
let touchPoint = touch.location(in: cropBorderView)
anchor = self.calculateAnchorBorder(touchPoint)
fillMultiplyer()
resizingEnabled = true
startPoint = touch.location(in: self.superview)
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
print("inside touches moved")
if let touch = touches.first {
if cropBorderView.frame.contains(touch.location(in: self)){
print("touch contains - touchesmoved")
//self.isUserInteractionEnabled = true
}
else{
print("Touch does not contain - touchesmoved ")
self.touchesCancelled(touches, with: event)
//return
}
if resizingEnabled! {
self.resizeWithTouchPoint(touch.location(in: self.superview))
}
}
}
It is indeed recognizing the touch when I click inside and outside as I wanted, but it is not stopping the touch when I click outside. This means calling self.touchesCancelled(touches, with: event) is not working. Calling return gives a crash and does not work as well. Are there any solutions to this problem?
Thank you for your time and consideration.
touchesCancelled(_:with:) just a notification for UITouch, it will not work this way.
As far as I understand, you implemented touch handlers in your overlay UIView, if so, you can try to replace the call to self.touchesCancelled(touches, with: event) with cancelTracking(with:) function from UIControl class implementation:
else {
print("Touch does not contain - touchesmoved ")
self.cancelTracking(with event)
}
Update solution, based on hitTest:
I've checked possible solutions and it seems that you can use hitTest: to avoid unnecessary touch recognitions. The following example is Swift Playground, you can tap and drag touches and see what happens in the console:
import UIKit
import PlaygroundSupport
class JSGView : UIView {
var centerView = UIView()
override func didMoveToSuperview() {
frame = CGRect(x: 0, y: 0, width: 320, height: 480)
backgroundColor = UIColor.clear
centerView.frame = CGRect(x: 110, y: 190, width: 100, height: 100)
centerView.backgroundColor = UIColor.blue
addSubview(centerView)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
dump(event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if let touch = touches.first {
if (hitTest(touch.location(in: self), with: event) != nil) {
print("Touch passed hit test and seems valid")
super.touchesCancelled(touches, with: event)
return
}
}
print("Touch isn't passed hit test and will be ignored")
super.touchesMoved(touches, with: event)
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if centerView.bounds.contains(centerView.convert(point, from: self)) {
return centerView
}
return nil
}
}
class JSGViewController : UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.frame = CGRect(x: 0, y: 0, width: 320, height: 480)
let customView = JSGView()
view.addSubview(customView)
}
}
let controller = JSGViewController()
PlaygroundPage.current.liveView = controller.view

let user to draw rectangle to select an area

I'm new in Swift and I'm trying to let the user draw a rectangle (touching and dragging) to select an area of an image just like when cropping but I don't want to crop I just want to know the CGRect the user created.
So far I have a .xib with a UIImage inside and its ViewController. I want to draw above the image but every tutorial I found about drawing is about subclassing UIView, override drawRect and put that as the xib class.
I figured it out. I just created a uiview and change its frame depending on the touches events
let overlay = UIView()
var lastPoint = CGPointZero
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
overlay.layer.borderColor = UIColor.blackColor().CGColor
overlay.backgroundColor = UIColor.clearColor().colorWithAlphaComponent(0.5)
overlay.hidden = true
self.view.addSubview(overlay)
}
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
//Save original tap Point
if let touch = touches.first {
lastPoint = touch.locationInView(self.view)
}
}
override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
//Get the current known point and redraw
if let touch = touches.first {
let currentPoint = touch.locationInView(view)
reDrawSelectionArea(lastPoint, toPoint: currentPoint)
}
}
func reDrawSelectionArea(fromPoint: CGPoint, toPoint: CGPoint) {
overlay.hidden = false
//Calculate rect from the original point and last known point
let rect = CGRectMake(min(fromPoint.x, toPoint.x),
min(fromPoint.y, toPoint.y),
fabs(fromPoint.x - toPoint.x),
fabs(fromPoint.y - toPoint.y));
overlay.frame = rect
}
override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
overlay.hidden = true
//User has lift his finger, use the rect
applyFilterToSelectedArea(overlay.frame)
overlay.frame = CGRectZero //reset overlay for next tap
}

Resources