UITextView link selectable without rest of text being selectable - ios

I'm trying to get a setup similar to what Facebook use (if they use a UITextView). I want links to be detected automatically however I don't want any other text in the UITextView selectable. So, the user can click on the link but is unable to select any other text.
Despite searching around, I've yet to come across a solution as for link selection to work it requires the whole of the text view to be selectable.

if your minimum deployment target is iOS 11.2 or newer
You can disable text selection by subclassing UITextView and forbidding the gestures that can select something.
The below solution is:
compatible with isEditable
compatible with isScrollEnabled
compatible with links
/// Class to allow links but no selection.
/// Basically, it disables unwanted UIGestureRecognizer from UITextView.
/// https://stackoverflow.com/a/49428307/1033581
class UnselectableTappableTextView: UITextView {
// required to prevent blue background selection from any situation
override var selectedTextRange: UITextRange? {
get { return nil }
set {}
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer {
// required for compatibility with isScrollEnabled
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}
if let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer,
tapGestureRecognizer.numberOfTapsRequired == 1 {
// required for compatibility with links
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}
// allowing smallDelayRecognizer for links
// https://stackoverflow.com/questions/46143868/xcode-9-uitextview-links-no-longer-clickable
if let longPressGestureRecognizer = gestureRecognizer as? UILongPressGestureRecognizer,
// comparison value is used to distinguish between 0.12 (smallDelayRecognizer) and 0.5 (textSelectionForce and textLoupe)
longPressGestureRecognizer.minimumPressDuration < 0.325 {
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}
// preventing selection from loupe/magnifier (_UITextSelectionForceGesture), multi tap, tap and a half, etc.
gestureRecognizer.isEnabled = false
return false
}
}
if your minimum deployment target is iOS 11.1 or older
Native UITextView links gesture recognizers are broken on iOS 11.0-11.1 and require a small delay long press instead of a tap: Xcode 9 UITextView links no longer clickable
You can properly support links with your own gesture recognizer and you can disable text selection by subclassing UITextView and forbidding the gestures that can select something or tap something.
The below solution will disallow selection and is:
compatible with isScrollEnabled
compatible with links
workaround limitations of iOS 11.0 and iOS 11.1, but loses the UI effect when tapping on text attachments
/// Class to support links and to disallow selection.
/// It disables most UIGestureRecognizer from UITextView and adds a UITapGestureRecognizer.
/// https://stackoverflow.com/a/49428307/1033581
class UnselectableTappableTextView: UITextView {
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
// Native UITextView links gesture recognizers are broken on iOS 11.0-11.1:
// https://stackoverflow.com/questions/46143868/xcode-9-uitextview-links-no-longer-clickable
// So we add our own UITapGestureRecognizer.
linkGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped))
linkGestureRecognizer.numberOfTapsRequired = 1
addGestureRecognizer(linkGestureRecognizer)
linkGestureRecognizer.isEnabled = true
}
var linkGestureRecognizer: UITapGestureRecognizer!
// required to prevent blue background selection from any situation
override var selectedTextRange: UITextRange? {
get { return nil }
set {}
}
override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
// Prevents drag and drop gestures,
// but also prevents a crash with links on iOS 11.0 and 11.1.
// https://stackoverflow.com/a/49535011/1033581
gestureRecognizer.isEnabled = false
super.addGestureRecognizer(gestureRecognizer)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == linkGestureRecognizer {
// Supporting links correctly.
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}
if gestureRecognizer is UIPanGestureRecognizer {
// Compatibility support with isScrollEnabled.
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}
// Preventing selection gestures and disabling broken links support.
gestureRecognizer.isEnabled = false
return false
}
#objc func textTapped(recognizer: UITapGestureRecognizer) {
guard recognizer == linkGestureRecognizer else {
return
}
var location = recognizer.location(in: self)
location.x -= textContainerInset.left
location.y -= textContainerInset.top
let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
let characterRange = NSRange(location: characterIndex, length: 1)
if let attachment = attributedText?.attribute(.attachment, at: characterIndex, effectiveRange: nil) as? NSTextAttachment {
if #available(iOS 10.0, *) {
_ = delegate?.textView?(self, shouldInteractWith: attachment, in: characterRange, interaction: .invokeDefaultAction)
} else {
_ = delegate?.textView?(self, shouldInteractWith: attachment, in: characterRange)
}
}
if let url = attributedText?.attribute(.link, at: characterIndex, effectiveRange: nil) as? URL {
if #available(iOS 10.0, *) {
_ = delegate?.textView?(self, shouldInteractWith: url, in: characterRange, interaction: .invokeDefaultAction)
} else {
_ = delegate?.textView?(self, shouldInteractWith: url, in: characterRange)
}
}
}
}

You can subclass the UITextView overriding the method of selectedTextRange, setting it to nil. And the links will still be clickable, but you won't be able to select the rest of the text (even the link but you can click on it).
class CustomTextView: UITextView {
override public var selectedTextRange: UITextRange? {
get {
return nil
}
set { }
}

You need to subclass UITextView and override gestureRecognizerShouldBegin (_:) method like this:
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if isEditable == false {
if let gesture = gestureRecognizer as? UILongPressGestureRecognizer, gesture.minimumPressDuration == 0.5 {
return false
}
}
return true
}
this will prevent from textview being selected but link will work as expected
Edited:
It turned out that when double tap and hold you are still able to select text. As I figured out it happens after two taps(not the UITapGesture with property "minimalNumberOfTaps", but to different taps one after another), so the solution is to track time after first step (approx. 0.7 sec)
Full code:
var lastTapTime: TimeInterval = 0
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if isEditable == false {
if let gesture = gestureRecognizer as? UILongPressGestureRecognizer, gesture.minimumPressDuration == 0.5 {
return false
}
}
if Date().timeIntervalSince1970 >= lastTapTime + 0.7 {
lastTapTime = Date().timeIntervalSince1970
return true
} else {
return false
}
}
This is not the most elegant solution but it seems to work 🤷‍♂️

The selected answer doesn't work in my case, and I'm not comfortable with comparing unconfirmed values inside of internal UIGestureRecognizers.
My solution was to override point(inside:with:) and allow a tap-through when the user is not touching down on linked text: https://stackoverflow.com/a/44878203/1153630

This is what worked for me;
class LinkDetectingTextView: UITextView {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if isEditable == false {
if let _ = gestureRecognizer as? UITapGestureRecognizer {
return false
}
if let longPressRecognizer = gestureRecognizer as? UILongPressGestureRecognizer,
longPressRecognizer.minimumPressDuration == 0.5 { // prevent to select text but allow certain functionality in application
return false
}
}
return true
}
}
In addition, set minimumPressDuration of the longPressGestureRecognizer in the application another value different than 0.5.

This answer is for iOS 10.3.x and below where your UIView is not embedded in a subview. For a more robust, modern answer, please see Cœur's answer below.
You need to prevent the UITextView from becoming first responder.
1. Subclass UITextView to your own custom class (MyTextView).
2. Override canBecomeFirstResponder(). Here's an example in Swift:
Swift 3:
class MyTextView: UITextView {
override func becomeFirstResponder() -> Bool {
return false
}
}
Swift 2:
class MyTextView: UITextView {
override func canBecomeFirstResponder() -> Bool {
return false
}
}
Any links detected will still be enabled. I tested this with a phone number.

Related

How to resolve the conflict of gestures between PDFView and SCrollView [Swift5, iOS 14]

I am creating a PDF viewer for iPads, with which users can read a PDF by scrolling horizontally.
I created the following code to implement the single page view with page change with gestures (while consulting with How to create a single page vertical scrolling PDFView in Swift and elsewhere).
Although this approach works fine most of the time, I realized that gestures are not detected (or called) when a PDF file is zoomed in. Because of this, I cannot go to the next page by swiping the screen. Playing with the extension PDFView {} function I created, I found out that disabling the user interaction in subview enables me to detect the swipe gestures. However, now I cannot scroll the page inside the PDFView. I would appreciate it if you could help me figure out how to fix this.
What I would like to implement is something like ‎PDF Expert (https://apps.apple.com/us/app/pdf-expert-pdf-reader-editor/id743974925), where I can scroll over to the next page horizontally.
Thank very much you for your help in advance!
import UIKit
import PDFKit
//PDF Zoom scale
var scaleOfPdf: CGFloat = 4
extension PDFView {
func disableBouncing(){
for subview in subviews{
if let scrollView = subview as? UIScrollView{
scrollView.bounces = false
return
}
}
class ViewController: UIViewController, UIGestureRecognizerDelegate, UIDocumentPickerDelegate {
#IBOutlet weak var pdfView: PDFView!
override func viewDidLoad(){
super.viewDidLoad()
pdfView.autoresizesSubviews = true
pdfView.autoresizingMask = [.flexibleWidth, .flexibleHeight, .flexibleTopMargin, .flexibleLeftMargin]
pdfView.displayDirection = .horizontal
pdfView.displayMode = .singlePage
pdfView.autoScales = true
// setting a color for background
pdfView.backgroundColor = .black
pdfView.document = pdfDocument
// pdfView.usePageViewController(true, withViewOptions: [UIPageViewController.OptionsKey.interPageSpacing: 20])
pdfView.maxScaleFactor = 4.0
pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit
pdfView.disableBouncing()
//setting swipe gesture
let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(respondLeftSwipeGesture(_:)))
leftSwipeGesture.direction = [UISwipeGestureRecognizer.Direction.left]
pdfView.addGestureRecognizer(leftSwipeGesture)
let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(respondRightSwipeGesture(_:)))
rightSwipeGesture.direction = [UISwipeGestureRecognizer.Direction.right]
pdfView.addGestureRecognizer(rightSwipeGesture)
}
//setting swipe-gesture
#objc func respondLeftSwipeGesture(_ sender: UISwipeGestureRecognizer) {
print("left swipe was detected")
if pdfView.document == nil { return }
scaleOfPdf = pdfView.scaleFactor
pdfView.goToNextPage(self)
pdfView.scaleFactor = scaleOfPdf
}
#objc func respondRightSwipeGesture(_ sender: UISwipeGestureRecognizer) {
print("right swipe was detected")
if pdfView.document == nil { return }
scaleOfPdf = pdfView.scaleFactor
pdfView.goToPreviousPage(self)
pdfView.scaleFactor = scaleOfPdf
}
}
Gesture Recognizers are working as a chain or pipeline that processes touches - after one (G1) fails, second one (G2) tries to recognize its gesture. Here you have at least 4 recognizers - your 2 ones (left and right), and the 2 scrollView's ones (pan and pinch). I will give the brief solution that covers only scrollView's pan recognizer, if you'll see problems also with pinch - you'll need to follow the same approach.
Let's say G1 is your left recognizer, and G2 is scrollView's pan recognizer.
In order to make G2 process the same touches as G1, they should be told to recognize simultaneously.
Also, the user might move his/her finger a bit horizontally while scrolling vertically, so in that case, you also want scrolling to start only after your G1 gives up on swipe and fails to recognize it.
In order to achieve that, you should add this code to your VC.
override func viewDidLoad(){
super.viewDidLoad()
...
leftSwipeGesture.delegate = self
leftSwipeGesture.cancelsTouchesInView = false
rightSwipeGesture.delegate = self
rightSwipeGesture.cancelsTouchesInView = false
}
optional func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return gestureRecognizer == leftSwipeGesture
|| gestureRecognizer == rightSwipeGesture
|| otherGestureRecognizer == leftSwipeGesture
|| otherGestureRecognizer == rightSwipeGesture
}
optional func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
guard let _ = gestureRecognizer as? UIPanGestureRecognizer else { return false }
return otherGestureRecognizer == leftSwipeGesture
|| otherGestureRecognizer == rightSwipeGesture
}
optional func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
guard let _ = otherGestureRecognizer as? UIPanGestureRecognizer else { return false }
return gestureRecognizer == leftSwipeGesture
|| gestureRecognizer == rightSwipeGesture
}
If UIGestureRecognizerDelegate methods that I added are not getting called, you'll need to create a subclass PDFView, make left/rightSwipeGesture.delegate = pdfView and override in your PDFView subclass its UIGestureRecognizerDelegate methods with this logic.

How to add tap gesture to a dimmed view background?

I've been trying this for awhile. The code below is my UIPresentationController. When a button is pressed, I add a dimmed UIView and a second modal (presentedViewController) pops up halfway.
I added the tap gesture recognizer in the method presentationTransitionWillBegin()
I don't know why the tap gesture is not being registered when I click on the dimmed UIView.
I've tried changing the "target" and adding the gesture in a different place. Also looked at other posts, but nothing has worked for me.
Thanks
import UIKit
class PanModalPresentationController: UIPresentationController {
override var frameOfPresentedViewInContainerView: CGRect {
var frame: CGRect = .zero
frame.size = size(forChildContentContainer: presentedViewController, withParentContainerSize: containerView!.bounds.size)
frame.origin.y = containerView!.frame.height * (1.0 / 2.0)
print("frameOfPresentedViewInContainerView")
return frame
}
private lazy var dimView: UIView! = {
print("dimView")
guard let container = containerView else { return nil }
let dimmedView = UIView(frame: container.bounds)
dimmedView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
dimmedView.isUserInteractionEnabled = true
return dimmedView
}()
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
print("init presentation controller")
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
}
override func presentationTransitionWillBegin() {
guard let container = containerView else { return }
print("presentation transition will begin")
container.addSubview(dimView)
dimView.translatesAutoresizingMaskIntoConstraints = false
dimView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true
dimView.leadingAnchor.constraint(equalTo: container.leadingAnchor).isActive = true
dimView.trailingAnchor.constraint(equalTo: container.trailingAnchor).isActive = true
dimView.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive = true
dimView.isUserInteractionEnabled = true
let recognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:)))
dimView.addGestureRecognizer(recognizer)
container.addSubview(presentedViewController.view)
presentedViewController.view.translatesAutoresizingMaskIntoConstraints = false
presentedViewController.view.bottomAnchor.constraint(equalTo: container.bottomAnchor).isActive = true
presentedViewController.view.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
presentedViewController.view.heightAnchor.constraint(equalTo: container.heightAnchor).isActive = true
guard let coordinator = presentingViewController.transitionCoordinator else { return }
coordinator.animate(alongsideTransition: { _ in
self.dimView.alpha = 1.0
})
print(dimView.alpha)
}
override func dismissalTransitionWillBegin() {
guard let coordinator = presentedViewController.transitionCoordinator else {
print("dismissal coordinator")
self.dimView.alpha = 0.0
return
}
print("dismissal transition begin")
coordinator.animate(alongsideTransition: { _ in
self.dimView.alpha = 0.0
})
}
override func containerViewDidLayoutSubviews() {
print("containerViewDidLayoutSubviews")
presentedView?.frame = frameOfPresentedViewInContainerView
// presentedViewController.dismiss(animated: true, completion: nil)
}
override func size(forChildContentContainer container: UIContentContainer, withParentContainerSize parentSize: CGSize) -> CGSize {
print("size")
return CGSize(width: parentSize.width, height: parentSize.height * (1.0 / 2.0))
}
#objc func handleTap(_ sender: UITapGestureRecognizer) {
print("tapped")
// presentingViewController.dismiss(animated: true, completion: nil)
presentedViewController.dismiss(animated: true, completion: nil)
}
}
I can't tell what the frame/bounds of your presentedViewController.view is but even if it's top half has an alpha of 0 it could be covering your dimView and receiving the tap events instead of the dimView - since presentedViewController.view is added as a subview on top of dimView.
You may have to wait until after the controller is presented and add the gesture to its superview's first subview. I've used this before to dismiss a custom alert controller with a background tap. You could probably do something similar:
viewController.present(alertController, animated: true) {
// Enabling Interaction for Transparent Full Screen Overlay
alertController.view.superview?.subviews.first?.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer(target: alertController, action: #selector(alertController.dismissSelf))
alertController.view.superview?.subviews.first?.addGestureRecognizer(tapGesture)
}
Hmm, try using this instead. Let me know how it goes. It works for me.
class PC: UIPresentationController {
/*
We'll have a dimming view behind.
We want to be able to tap anywhere on the dimming view to do a dismissal.
*/
override var frameOfPresentedViewInContainerView: CGRect {
let f = super.frameOfPresentedViewInContainerView
var new = f
new.size.height /= 2
new.origin.y = f.midY
return new
}
override func presentationTransitionWillBegin() {
let con = self.containerView!
let v = UIView(frame: con.bounds)
v.backgroundColor = UIColor.black
v.alpha = 0
con.insertSubview(v, at: 0)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
v.addGestureRecognizer(tap)
let tc = self.presentedViewController.transitionCoordinator!
tc.animate(alongsideTransition: { _ in
v.alpha = 1
}, completion: nil)
}
#objc func handleTap() {
print("tapped")
self.presentedViewController.dismiss(animated: true, completion: nil)
}
override func dismissalTransitionWillBegin() {
let con = self.containerView!
let v = con.subviews[0]
let tc = self.presentedViewController.transitionCoordinator!
tc.animate(alongsideTransition: { _ in
v.alpha = 0
}, completion: nil)
}
}
I took a look at your project just now. The problem is in your animation controller. If you comment out the functions in your transition delegate object that vend animation controllers, everything works fine.
But just looking at your animation controller, what you wanted to achieve was to have your new vc slide up / slide down. And in fact, you don't even need a custom animation controller for this; the modalTransitionStyle property of a view controller has a default value of coverVertical, which is just what you want I think.
In any case though, you can still use the presentation controller class I posted before, as it has same semantics from your class, just without unnecessary overrides.
Optional
Also just a tip if you'd like, you have these files right now in your project:
PanModalPresentationDelegate.swift
PanModalPresentationController.swift
PanModalPresentationAnimator.swift
TaskViewController.swift
HomeViewController.swift
What I normally do is abbreviate some of those long phrases, so that the name of the file and class conveys the essence of its nature without long un-needed boilerplate.
So HomeViewController and TaskViewController would be Home_VC and Task_VC. Those other 3 files are all for the presentation of one VC; it can get out of hand very quickly. So what I normally do there is call my presentation controller just PC and nest its declaration inside the VC class that will use it (in this case that's Task_VC). Until the time comes where it needs to be used by some other VC too; then it's more appropriate to put it in its own file and call it Something_PC but I've never actually needed to do that yet lol. And the same for any animation controllers ex. Fade_AC, Slide_AC etc. I tend to call transition delegate a TransitionManager and nest it in the presented VC's class. Makes it easier for me to think of it as just a thing that vends AC's / a PC.
Then your project simply becomes:
Home_VC.swift
Task_VC.swift
And if you go inside Task_VC, you'll see a nested TransitionManager and PC.
But yeah up to you 😃.
The dimmedView is behind presented view. You have a couple options to correct that.
First, is allow touches to pass through the top view, it must override pointInside:
- (BOOL) pointInside:(CGPoint)point withEvent:(UIEvent *)event {
for (UIView *subview in self.subviews) {
if ([subview hitTest:[self convertPoint:point toView:subview] withEvent:event]) {
return TRUE;
}
}
return FALSE;
}
Another options is to instead add the gesture recognizer to the presentedViewController.view, instead of the dimmedView. And, if you allow PanModalPresentationController to adopt the UIGestureRecognizerDelegate, and it as the delegate to the recognizer, you can determine if you should respond to touches, by implementing shouldReceive touch:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
if (touch.view == presentedViewController.view) {
return true
}
return false
}
If you use the second option, don't forget to remove the gesture recognizer in dismissalTransitionWillBegin or dismissalTransitionDidEnd!

How can selection be disabled in PDFView?

Displaying a PDFDocument in a PDFView allows the user to select parts of the document and perform actions e.g. "copy" with the selection.
How can selection be disabled in a PDFView while preserving the possibility for the user to zoom in and out and scroll in the PDF?
PDFView itself does not seem to offer such a property nor does the PDFViewDelegate.
You have to subclass PDFView, as such:
class MyPDFView: PDFView {
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return false
}
override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer is UILongPressGestureRecognizer {
gestureRecognizer.isEnabled = false
}
super.addGestureRecognizer(gestureRecognizer)
}
}
Just need to do is it will auto clear the selection and User will no longer long-press on PDF text.
class MyPDFView: PDFView {
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
self.currentSelection = nil
self.clearSelection()
return false
}
override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer is UILongPressGestureRecognizer {
gestureRecognizer.isEnabled = false
}
super.addGestureRecognizer(gestureRecognizer)
}
}
This below 2 lines need to add in canPerformAction()
self.currentSelection = nil
self.clearSelection()
For iOS 13, the above solution no longer works. It looks like they've changed the internal implementation of PDFView and specifically how the gesture recognizers are set up. I think generally it's discouraged to do this kind of thing, but it can still be done without using any internal API, here's how:
1) Recursively gather all subviews of PDFView (see below for the helper function to do this)
let allSubviews = pdfView.allSubViewsOf(type: UIView.self)
2) Iterate over them and deactivate any UILongPressGestureRecognizers:
for gestureRec in allSubviews.compactMap({ $0.gestureRecognizers }).flatMap({ $0 }) {
if gestureRec is UILongPressGestureRecognizer {
gestureRec.isEnabled = false
}
}
Helper func to recursively get all subviews of a given type:
func allSubViewsOf<T: UIView>(type: T.Type) -> [T] {
var all: [T] = []
func getSubview(view: UIView) {
if let aView = view as? T {
all.append(aView)
}
guard view.subviews.count > 0 else { return }
view.subviews.forEach{ getSubview(view: $0) }
}
getSubview(view: self)
return all
}
I'm calling the above code from the viewDidLoad method of the containing view controller.
I haven't yet found a good way to work this into a subclass of PDFView, which would be the preferred way for reusability and could just be an addition to the above NonSelectablePDFView. What I've tried so far is overriding didAddSubview and adding the above code after the call to super, but that didn't work as expected. It seems like the gesture recognizers are only being added at a later step, so figuring out when that is and if there's a way for the subclass to call some custom code after this happened would be a next step here.
With Swift 5 and iOS 12.3, you can solve your problem by overriding addGestureRecognizer(_:) method and canPerformAction(_:withSender:) method in a PDFView subclass.
import UIKit
import PDFKit
class NonSelectablePDFView: PDFView {
override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
(gestureRecognizer as? UILongPressGestureRecognizer)?.isEnabled = false
super.addGestureRecognizer(gestureRecognizer)
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return false
}
}
As an alternative to the previous implementation, you can simply toggle UILongPressGestureRecognizer isEnabled property to false in the initializer.
import UIKit
import PDFKit
class NonSelectablePDFView: PDFView {
override init(frame: CGRect) {
super.init(frame: frame)
if let gestureRecognizers = gestureRecognizers {
for gestureRecognizer in gestureRecognizers where gestureRecognizer is UILongPressGestureRecognizer {
gestureRecognizer.isEnabled = false
}
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return false
}
}
You should note that this is not sufficient to disable text selecting, as there is a UITapAndHalfRecognizer as well – obviously a private Apple class - that also creates selections.
It is attached to the PDFDocumentView, which is another private implementation detail of PDFView, and which you can not replace with your own class implementation.

Dismiss copy/paste contextual menu and remove selection in UIWebView

I have a UIWebView in which I've implemented a single tap gesture recognizer. I don't have any recognizer for long hold, though my problem is in making a selection. Issues:
Select some text in a UIWebView, select copy from contextual menu. Menu should disappear.
Long hold to make a text selection, single tap gesture is also getting called.
If text is selected, a single tap elsewhere should remove the selection like in Safari.
…
func createGestureRecognizer() {
// single tap
let singleTapGesture = UITapGestureRecognizer(target: self, action: "handleSingleTap:")
singleTapGesture.numberOfTapsRequired = 1
singleTapGesture.delegate = self
singleTapGesture.cancelsTouchesInView = false
webView.addGestureRecognizer(singleTapGesture)
}
func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldReceiveTouch touch: UITouch) -> Bool {
if touch.view.isDescendantOfView(self.webView) {
return true
}
return true
}
func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if otherGestureRecognizer.isKindOfClass(UITapGestureRecognizer) {
otherGestureRecognizer.requireGestureRecognizerToFail(gestureRecognizer)
// println("added failure requirement to: \(otherGestureRecognizer)")
}
return true
}
func handleSingleTap(tap: UITapGestureRecognizer) {
…
}
EDIT: It is sort of working. Problem: Making an initial text selection requires a longer hold than normal. A normal long hold that would normally make a selection calls my single tap recognizer without selecting any text.
func handleSingleTap(tap: UITapGestureRecognizer) {
let selection = webView.stringByEvaluatingJavaScriptFromString("window.getSelection().toString()")!
if selection == "" {
// do stuff
} else {
webView.userInteractionEnabled = false
webView.userInteractionEnabled = true
webView.stringByEvaluatingJavaScriptFromString("window.getSelection().removeAllRanges()")
}

Using "Next" as a Return Key

I use the "Next" value for the "Return Key" to get the Next button in place of the Done button, but (obviously) pressing it doesn't automatically move to the next UITextField in my view.
What's the right way to do this? I have seen many answers, but anyone have a swift solution?
Make sure your text fields have their delegate set and implement the textFieldShouldReturn method. This is the method that is called when the user taps the return key (no matter what it looks like).
The method might look something like this:
func textFieldShouldReturn(textField: UITextField) -> Bool {
if textField == self.field1 {
self.field2.becomeFirstResponder()
}
return true
}
The actual logic in here might vary. There are numerous approaches, and I'd definitely advise against a massive if/else chain if you have lots of text fields, but the gist here is to determine what view is currently active in order to determine what view should become active. Once you've determined which view should become active, call that view's becomeFirstResponder method.
For some code cleanliness, you might consider a UITextField extension that looks something like this:
private var kAssociationKeyNextField: UInt8 = 0
extension UITextField {
var nextField: UITextField? {
get {
return objc_getAssociatedObject(self, &kAssociationKeyNextField) as? UITextField
}
set(newField) {
objc_setAssociatedObject(self, &kAssociationKeyNextField, newField, .OBJC_ASSOCIATION_RETAIN)
}
}
}
And then change our textFieldShouldReturn method to look like this:
func textFieldShouldReturn(textField: UITextField) -> Bool {
textField.nextField?.becomeFirstResponder()
return true
}
Once you've done this, it should simply be a matter of setting each text field's new nextField property in viewDidLoad:
self.field1.nextField = self.field2
self.field2.nextField = self.field3
self.field3.nextField = self.field4
self.field4.nextField = self.field1
Although if we really wanted, we could prefix the property with #IBOutlet, and that would allow us to hook up our "nextField" property right in interface builder.
Change the extension to look like this:
private var kAssociationKeyNextField: UInt8 = 0
extension UITextField {
#IBOutlet var nextField: UITextField? {
get {
return objc_getAssociatedObject(self, &kAssociationKeyNextField) as? UITextField
}
set(newField) {
objc_setAssociatedObject(self, &kAssociationKeyNextField, newField, .OBJC_ASSOCIATION_RETAIN)
}
}
}
And now hook up the nextField property in interface builder:
(Set up your delegate while you're here too.)
And of course, if the nextField property returns nil, the keyboard just hides.
Here is an example in Swift:
I created a screen with 6 UITextFields. I assigned them the tags 1 through 6 in Interface Builder. I also changed the Return key to Next in IB. Then I implemented the following:
import UIKit
// Make your ViewController a UITextFieldDelegate
class ViewController: UIViewController, UITextFieldDelegate {
// Use a dictionary to define text field order 1 goes to 2, 2 goes to 3, etc.
let nextField = [1:2, 2:3, 3:4, 4:5, 5:6, 6:1]
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
// Make ourselves the delegate of the text fields so that textFieldShouldReturn
// will be called when the user hits the Next/Return key
for i in 1...6 {
if let textField = self.view.viewWithTag(i) as? UITextField {
textField.delegate = self
}
}
}
// This is called when the user hits the Next/Return key
func textFieldShouldReturn(textField: UITextField) -> Bool {
// Consult our dictionary to find the next field
if let nextTag = nextField[textField.tag] {
if let nextResponder = textField.superview?.viewWithTag(nextTag) {
// Have the next field become the first responder
nextResponder.becomeFirstResponder()
}
}
// Return false here to avoid Next/Return key doing anything
return false
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
}
There is nothing wrong with the other answers, this is just a different approach with the benefit of being more focused on OOP - imho (although this is a bit more work up front, it can be reused). In the storyboard, I start off adding tags with a distinct range (e.g 800-810) that define the specific order of the fields I want to move between. This has the benefit of working across all subviews in the main view so that one can navigate between UITextField's and UITextView's (and any other control) as needed.
Generally - I typically try to have view controllers message between views and custom event handler objects. So I use a message (aka, NSNotification) passed back to the view controller from a custom delegate class.
(TextField Delegate Handler)
Note: In AppDelegate.swift: let defaultCenter = NSNotificationCenter.defaultCenter()
//Globally scoped
struct MNGTextFieldEvents {
static let NextButtonTappedForTextField = "MNGTextFieldHandler.NextButtonTappedForTextField"
}
class MNGTextFieldHandler: NSObject, UITextFieldDelegate {
var fields:[UITextField]? = []
func textField(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Bool {
return true
}
func textFieldDidBeginEditing(textField: UITextField) {
textField.backgroundColor = UIColor.yellowColor()
}
func textFieldDidEndEditing(textField: UITextField) {
textField.backgroundColor = UIColor.whiteColor()
}
func textFieldShouldBeginEditing(textField: UITextField) -> Bool {
return true
}
func textFieldShouldClear(textField: UITextField) -> Bool {
return false
}
func textFieldShouldEndEditing(textField: UITextField) -> Bool {
return true
}
func textFieldShouldReturn(textField: UITextField) -> Bool {
//passes the message and the textField (with tag) calling the method
defaultCenter.postNotification(NSNotification(name: MNGTextFieldEvents.NextButtonTappedForTextField, object: textField))
return false
}
}
This allows my view controller to remain focused on it's main job of handling the messaging between objects, model and view.
(View Controller receives a message from the delegate and passes instructions using the advanceToNextField function)
Note: In my storyboard my custom handler classes are defined using an NSObject and that object is linked into the storyboard as a delegate for the controls that I need monitored. Which causes the custom handler class to be initialized automatically.
class MyViewController: UIViewController {
#IBOutlet weak var tagsField: UITextField! { didSet {
(tagsField.delegate as? MNGTextFieldHandler)!.fields?.append(tagsField)
}
}
#IBOutlet weak var titleField: UITextField!{ didSet {
(titleField.delegate as? MNGTextFieldHandler)!.fields?.append(titleField)
}
}
#IBOutlet weak var textView: UITextView! { didSet {
(textView.delegate as? MNGTextViewHandler)!.fields?.append(textView)
}
}
private struct Constants {
static let SelectorAdvanceToNextField = Selector("advanceToNextField:")
}
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
registerEventObservers()
}
override func viewDidDisappear(animated: Bool) {
super.viewDidDisappear(animated)
deRegisterEventObservers()
}
func advanceToNextField(notification:NSNotification) {
let currentTag = (notification.object as! UIView).tag
for aView in self.view.subviews {
if aView.tag == currentTag + 1 {
aView.becomeFirstResponder()
}
}
}
func registerEventObservers () {
defaultCenter.addObserver(self, selector: Constants.SelectorAdvanceToNextField, name: MNGTextFieldEvents.NextButtonTappedForTextField, object: nil)
}
func deRegisterEventObservers() {
defaultCenter.removeObserver(self, name: MNGTextFieldEvents.NextButtonTappedForTextField, object: nil)
}
....
}
Just another way to achieve the result that I found helpful. My app had 11 text fields followed by a text view. I needed to be able to cycle through all fields using the next key and then resign the keyboard following the textview (i.e. other notes).
In the storyboard, I set the tag on all of the fields (both text and textview) starting with 1 through 12, 12 being the textview.
I'm sure there are other ways to do it and this method isn't perfect, but hopefully it helps someone.
In code, I wrote the following:
func textFieldShouldReturn(textField: UITextField) -> Bool {
let nextTag = textField.tag + 1
//Handle Textview transition, Textfield programmatically
if textField.tag == 11 {
//Current tag is 11, next field is a textview
self.OtherNotes.becomeFirstResponder()
} else if nextTag > 11 {
//12 is the end, close keyboard
textField.resignFirstResponder()
} else {
//Between 1 and 11 cycle through using next button
let nextResponder = self.view.viewWithTag(nextTag) as? UITextField
nextResponder?.becomeFirstResponder()
}
return false
}
func textFieldDidEndEditing(textField: UITextField) {
textField.resignFirstResponder()
}
func textView(textView: UITextView, shouldChangeTextInRange range: NSRange, replacementText text: String) -> Bool {
//Remove keyboard when clicking Done on keyboard
if(text == "\n") {
textView.resignFirstResponder()
return false
}
return true
}
Another approach, if you're using storyboards, you can change the textfield's attribute for Return Key.
Currently you have the following options: Default (Return), Go, Google, Join, Next, Route, Search, Send, Yahoo, Done, Emergency Call, Continue

Resources