Can't observe UIPageControl's currentPage changes in Swift - ios

I've a subclass of UIPageControl, and I'd like to observe the changes in currentPage.
Unfortunately, my currentPage's didSet isn't called when currentPage changes from taps on MyPageControl.
class MyPageControl: UIPageControl {
override var currentPage: Int {
didSet {
// not called when `currentPage` is changed from a tap
updateSomething(for: currentPage)
}
}
}
And the change can't be observed using KVO either:
class MyPageControl: UIPageControl {
var observation: NSKeyValueObservation?
override func awakeFromNib() {
super.awakeFromNib()
observation = observe(\.currentPage) { (_, _) in
// not called when `currentPage` is changed from a tap
updateSomething(for: self.currentPage)
}
}
}

From Apple Doc : Apple Doc
When a user taps a page control to move to the next or previous page, the control sends the valueChanged event for handling by the delegate. The delegate can then evaluate the currentPage property to determine the page to display. The page control advances only one page in either direction. The currently viewed page is indicated by a white dot. Depending on the device, a certain number of dots are displayed on the screen before they are clipped.
Code:
pageControl.addTarget(self, action:#selector(pageControlTapHandler(sender:)), for: .touchUpInside)
//or
pageControl.addTarget(self, action:#selector(pageControlTapHandler(sender:)), for: .valueChanged)
...
func pageControlTapHandler(sender:UIPageControl) {
print("currentPage:", sender.currentPage) //currentPage: 1
}

You may detect the taps with sendAction(_:to:for:) and read the value of currentPage at that moment.
class MyPageControl: UIPageControl {
override func sendAction(_ action: Selector, to target: Any?, for event: UIEvent?) {
super.sendAction(action, to: target, for: event)
updateSomething(for: currentPage)
}
}

Related

How to detect number of taps

I want to detect number of taps
User can tap multiple times and I have to perform action based on number of taps.
I tried using UIButton with the below code but its detecting all the taps
if I tap three times, It prints
1
2
3
Code -
tapButton.addTarget(self, action: #selector(multipleTap(_:event:)), for: UIControl.Event.touchDownRepeat)
#objc func multipleTap(_ sender: UIButton, event: UIEvent) {
let touch: UITouch = event.allTouches!.first!
print(touch.tapCount)
}
I need the output to be just 3 if i tap three times.
Edit 1: for ex - if you tap three times in youtube it will forward 30 seconds and if you tap 4 times it will forward 40 secs.
You aren’t defining the problem clearly. Are you saying that you want to detect a group of repeated taps within a short time as a single, multi-tap event, and report the number of taps? If so, you need to add logic to do that. Decide how long to wait between taps before you consider the event complete.
You’ll then need to keep track of how many taps have occurred, and how long it’s been since the last tap. You’ll need a timer that fires when the inter-tap interval has passed with no new taps so that you can consider the event complete.
All this sounds a lot more like a tap gesture recognizer than a button. Tap gesture recoginizers are written to detect a specific number of taps, with timeouts and such, but don’t respond to variable numbers of taps. You might want to create a custom gesture recognizer that responds to a variable number of taps instead of a button.
introduce var to hold tap count
your modified code will be:
tapButton.addTarget(self, action: #selector(singleTap(_:)), for: .touchUpInside)
private var numberOfTaps = 0
private var lastTapDate: Date?
#objc private func singleTap(_ sender: UIButton) {
if let lastTapDate = lastTapDate, Date().timeIntervalSince(lastTapDate) <= 1 { // less then a second
numberOfTaps += 1
} else {
numberOfTaps = 0
}
lastTapDate = Date()
if numberOfTaps == 3 {
// do your rewind stuff here 30 sec
numberOfTaps = 0
}
}
edit: I'm not mind reader, but I guess you look for something like above (updated code)
class ViewController: UIViewController {
var tapCount: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
tapButton.addTarget(self, action: #selector(multipleTap(sender:)), for: .touchUpInside)
}
#objc func multipleTap(sender: UIButton) {
tapCount += 1
if tapCount == 3 {
print(tapCount) //3
}
}
}
I'm not sure for what exactly you need this, but below Duncan's answer I read that you need to copy something from YouTube logic.
Yes, of course you can use tap gesture recognizer, but if you need from some reason UIButton, you can create its subclass.
After first touch nothing happens, but then after every next touch valueChanged handler which you will set in view controller gets called. If you don't press button again to certain wait duration, touches will be reset to 0.
class TouchableButton: UIButton {
var waitDuration: Double = 1.5 // change this for custom duration to reset
var valueChanged: (() -> Void)? // set this to handle press of button
var minimumTouches: Int = 2 // set this to change number of minimum presses
override init(frame: CGRect) {
super.init(frame: frame)
setTarget()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setTarget()
}
private func setTarget() {
addTarget(self, action: #selector(buttonTouched), for: .touchUpInside)
}
#objc private func buttonTouched() {
touches += 1
}
private var timer: Timer?
private var touches: Int = 0 {
didSet {
if touches >= minimumTouches {
valueChanged?()
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: waitDuration, repeats: false) { _ in
self.touches = 0
}
}
}
}
}
then when you need to set what should happen after every touch, you can set value changed handler
button.valueChanged = { // button of type `TouchableButton`
//print("touched")
... // move 10s forward or backwards
}
you can also change waitDuration property which specify wait time between last press and time when touches will be reset
button.waitDuration = 1
also you can set number of minimum touches (first time when valueChanged gets executed)
button.minimumTouches = 3
You need to check time difference for touch events.
For ex: If button is pressed 4 times and in particular time, say 1 sec, if it does not get pressed again then you can print 4 taps otherwise don't print.
For this you also need to call the timer so that you can check the time of last event and print accordingly.
var timer : Timer?
var timeDuration = 2.0
var tapCount = 0
var lastButtonPressedTime : Date?
#IBAction func tapCountButtonAction(_ sender: UIButton) {
lastButtonPressedTime = Date()
if(tapCount == 0){
tapCount = 1
timer = Timer.scheduledTimer(withTimeInterval: timeDuration, repeats: true, block: { (timer) in
let difference = Calendar.current.dateComponents([.second], from:self.lastButtonPressedTime!, to:Date())
if(difference.second! > 1){
print(self.tapCount)
timer.invalidate()
self.tapCount = 0
}
})
}else{
tapCount += 1
}
}
The technique used here is introducing a time delay in executing the
final action
static let tapCount = 0
tapButton.addTarget(self, action: #selector(multipleTap(_:event:)), for: UIControl.Event.touchDownRepeat)
// An average user can tap about 7 times in 1 second using 1 finger
DispatchQueue.main.asyncAfter(deadLine: .now() + 1.0 /* We're waiting for a second till executing the target method to observe the total no of taps */ ) {
self.performAction(for: tapCount)
}
#objc func multipleTap(_ sender: UIButton, event: UIEvent) {
tapCount += 1
}
/// Perform the respective action based on the taps
private func performAction(for count: Int) {
// Resetting the taps after 1 second
tapCount = 0
switch (count){
case 2:
// Handle 2 clicks
case 3:
// Handle 3 clicks
case 4:
// Handle 4 clicks
default:
print("Action undefined for count: \(count)")
}
}

How to Focus Accessibility On A Particular Segment in A UISegmentedControl

OK. This answer helps a lot. I can select an accessibility item when a screen is shown. I simply add
UIAccessibility.post(notification: .layoutChanged, argument: <a reference to the UI item to receive focus>)
to the end of my viewWillAppear() method, and the item receives focus.
However, in one of my screens, the item I want to receive focus is a UISegmentedControl, and, when focused, it always selects the first item, no matter which one is selected. Since I followed the excellent suggestion here, I have an accessibility label for each item in the control, and I'd like my focus to begin on whichever segment is selected.
Is there a way to do this? As a rule, I try to avoid "hacky" solutions (like the one I just referenced), but I'm willing to consider anything.
Thanks!
UPDATE: Just to add insult to injury, I am also having an issue with the item I want selected being selected, then a second later, the screen jumps the selection to the first item. That's probably a topic for a second question.
I created a blank project as follows to reproduce the problem:
The solution is taking the selectedIndex to display the selected segment and providing the appropriate segment object for the VoiceOver notification: easy, isn't it?
I naively thought that getting the subview in the segmented control subviews array with the selectedIndex would do the job but that's definitely not possible because the subviews can move inside this array as the following snapshot highlights (red framed first element for instance):
The only way to identify a unique segment is its frame, so I pick up the segmented control index and the frame of the selected segment to pass them to the previous view controller.
That will allow to display (index) and read out (frame that identifies the object for the notification) the appropriate selected segment when this screen will appear after the transition.
Hereafter the code snippets for the view controller that contains the 'Next Screen' button:
class SOFSegmentedControl: UIViewController, UpdateSegmentedIndexDelegate {
var segmentIndex = 0
var segmentFrame = CGRect.zero
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let segueName = segue.identifier {
if (segueName == "SegmentSegue") {
if let destVC = segue.destination as? SOFSegmentedControlBis {
destVC.delegate = self
destVC.segmentIndex = segmentIndex
destVC.segmentFrame = segmentFrame
}
}
}
}
#IBAction func buttonAction(_ sender: UIButton) { self.performSegue(withIdentifier: "SegmentSegue", sender: sender) }
func updateSegmentIndex(_ index: Int, withFrame frame: CGRect) {
segmentIndex = index
segmentFrame = frame
}
}
... and for the view controller that displays the segmented control:
protocol UpdateSegmentedIndexDelegate: class {
func updateSegmentIndex(_ index: Int, withFrame frame: CGRect)
}
class SOFSegmentedControlBis: UIViewController {
#IBOutlet weak var mySegmentedControl: UISegmentedControl!
var delegate: UpdateSegmentedIndexDelegate?
var segmentFrame = CGRect.zero
var segmentIndex = 0
var segmentFrames = [Int:CGRect]()
override func viewDidLoad() {
super.viewDidLoad()
mySegmentedControl.addTarget(self,
action: #selector(segmentedControlValueChanged(_:)),
for: .valueChanged)
mySegmentedControl.selectedSegmentIndex = segmentIndex
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
print(mySegmentedControl.subviews)
let sortedFrames = mySegmentedControl.subviews.sorted(by: { $0.frame.origin.x < $1.frame.origin.x})
for (index, segment) in sortedFrames.enumerated() { segmentFrames[index] = segment.frame }
if (self.segmentFrame == CGRect.zero) {
UIAccessibility.post(notification: .screenChanged,
argument: mySegmentedControl)
} else {
mySegmentedControl.subviews.forEach({
if ($0.frame == self.segmentFrame) {
UIAccessibility.post(notification: .screenChanged,
argument: $0)
}
})
}
}
#objc func segmentedControlValueChanged(_ notif: NSNotification) {
delegate?.updateSegmentIndex(mySegmentedControl.selectedSegmentIndex,
withFrame: segmentFrames[mySegmentedControl.selectedSegmentIndex]!) }
}
The final result is as follows:
Double tap to go to the next screen.
Select the next element to focus the second segment.
Double tap to select the focused element.
Get back to the previous screen thanks to the Z gesture natively known by iOS with the navigation controller. The delegate passes the index and the frame of the selected segment.
Double tap to go to the next screen.
The segment that was formerly selected is read out by VoiceOver and still selected.
You can now Focus Accessibility On A Particular Segment in A UISegmentedControl following this rationale.
I try to avoid "hacky" solutions (like the one I just referenced), but I'm willing to consider anything.
Unfortunately, this solution is a hacky one... sorry. However, it works and I couldn't find another one anywhere else: see it as a personal fix unless you get a cleaner one to share? ;o)
UPDATE... That's probably a topic for a second question.
I can't reproduce the behavior of your update: if you create a dedicated topic for this problem, please add the most detailed code and context so as to provide the most accurate solution.
i think this works~!
class VC {
let segment = UISegmentedControl()
func fucusSegment(index: Int) {
let item = segment.accessibilityElement(at: index )
UIAccessibility.post(notification: .layoutChanged, argument: item)
}
}

UITextView with Gesture Recognizer - Conditionally Forward Touch to Parent View

I have a UITextView embedded in a UITableViewCell.
The text view has scrolling disabled, and grows in height with the text in it.
The text view has a link-like section of text that is attributed with a different color and underlined, and I have a tap gesture recognizer attached to the text view that detects whether the user tapped on the "link" portion of the text or not (This is accomplished using the text view's layoutManager and textContainerInset to detect whether the tap falls within the 'link' or not. It's basically a custom hit test function).
I want the table view cell to receive the tap and become selected when the user "misses" the link portion of the text view, but can't figure out how to do it.
The text view has userInteractionEnabled set to true. However, this does not block the touches from reaching the table view cell when there is no gesture recognizer attached.
Conversely, if I set it to false, for some reason cell selection stops altogether, even when tapping outside of the text view's bounds (but the gesture recognizer still works... WHY?).
What I've Tried
I have tried overriding gestureRecognizer(_ :shouldReceive:), but even when I return false, the table view cell does not get selected...
I have also tried implementing gestureRecognizerShouldBegin(_:), but there too, even if I perform my hit test and return false, the cell does not get the tap.
How can I forward the missed taps back to the cell, to highlight it?
After trying Swapnil Luktuke's answer(to the extent that I understood it, at least) to no avail, and every possible combination of:
Implementing the methods of UIGestureRecognizerDelegate,
Overriding UITapGestureRecognizer,
Conditionally calling ignore(_:for:), etc.
(perhaps in my desperation I missed something obvious, but who knows...)
...I gave up and decided to follow the suggestion by #danyapata in the comments to my question, and subclass UITextView.
Partly based on code found on this Medium post, I came up with this UITextView subclass:
import UIKit
/**
Detects taps on subregions of its attributed text that correspond to custom,
named attributes.
- note: If no tap is detected, the behavior is equivalent to a text view with
`isUserInteractionEnabled` set to `false` (i.e., touches "pass through"). The
same behavior doesn't seem to be easily implemented using just stock
`UITextView` and gesture recognizers (hence the need to subclass).
*/
class LinkTextView: UITextView {
private var tapHandlersByName: [String: [(() -> Void)]] = [:]
/**
Adds a custom block to be executed wjhen a tap is detected on a subregion
of the **attributed** text that contains the attribute named accordingly.
*/
public func addTapHandler(_ handler: #escaping(() -> Void), forAttribute attributeName: String) {
var handlers = tapHandlersByName[attributeName] ?? []
handlers.append(handler)
tapHandlersByName[attributeName] = handlers
}
// MARK: - Initialization
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
commonSetup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func awakeFromNib() {
super.awakeFromNib()
commonSetup()
}
private func commonSetup() {
self.delaysContentTouches = false
self.isScrollEnabled = false
self.isEditable = false
self.isUserInteractionEnabled = true
}
// MARK: - UIView
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let attributeName = self.attributeName(at: point), let handlers = tapHandlersByName[attributeName], handlers.count > 0 else {
return nil // Ignore touch
}
return self // Claim touch
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
// find attribute name
guard let touch = touches.first, let attributeName = self.attributeName(at: touch.location(in: self)) else {
return
}
// Execute all handlers for that attribute, once:
tapHandlersByName[attributeName]?.forEach({ (handler) in
handler()
})
}
// MARK: - Internal Support
private func attributeName(at point: CGPoint) -> String? {
let location = CGPoint(
x: point.x - self.textContainerInset.left,
y: point.y - self.textContainerInset.top)
let characterIndex = self.layoutManager.characterIndex(
for: location,
in: self.textContainer,
fractionOfDistanceBetweenInsertionPoints: nil)
guard characterIndex < self.textStorage.length else {
return nil
}
let firstAttributeName = tapHandlersByName.allKeys.first { (attributeName) -> Bool in
if self.textStorage.attribute(NSAttributedStringKey(rawValue: attributeName), at: characterIndex, effectiveRange: nil) != nil {
return true
}
return false
}
return firstAttributeName
}
}
As ususal, I'll wait a couple of days before accepting my own answer, just in case something better shows up...
Keep all your views active (i.e. user interaction enabled).
Loop through the text view's gestures and disable the ones you do not need.
Loop through the table view's gestureRecognisers array, and make them depend on the text view's custom tap gesture using requireGestureRecognizerToFail.
If its a static table view, you can do this in view did load. For a dynamic table view, do this in 'willDisplayCell' for the text view cell.

Programmatically Tap View

I have created a custom view that is to be used as a radio button with images and text. I need to be able to load the saved selection when the controller loads. I set my listeners this way:
for button in genderButtons {
button.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(genderTapped(_:))))
}
#objc private func genderTapped(_ sender: UITapGestureRecognizer) {
for button in genderButtons {
button.select(sender.view! == button) // Toggles the button to display selected/deslected state.
...
}
}
The problem is that I can't find a way to tell the view to select. I tried making the gesture recognizer and object, but it doesn't have any methods I can use to trigger it. The 'buttons' aren't actually buttons, they're views, so I can't send an action event.
How can I select the correct button with code?
Just call genderTapped directly, handing it the gesture recognizer already attached to the desired "button".
For example, if thisGenderButton is the one you want to "tap", say:
if let tap = thisGenderButton.gestureRecognizers?[0] as? UITapGestureRecognizer {
genderTapped(tap)
}
You can add this method in your customView like this,
Class CustomView: UIView {
public func select(_ value: Bool) {
self.backgroundColor = value ? .green: .red
}
}
and then in below method you can call select for the tapped view.
#objc private func genderTapped(_ sender: UITapGestureRecognizer) {
(sender.view as? CustomView)?.select(true)
}

UIControl blocking all my views on iPhone

I got a iPad-designed app using a SplitViewController showing two views, one with a contacts list and another with details of this contact. The SplitView works well on iPad but has some problems on iPhones.
There is a UIControl that take all the size of the Master View, that check if there is any .touchDown interaction by the user and some methods called to enable or disable this UIControl depending if we are on editing contact mode or not allowing user to interact with the screen or not :
private var disableInteractionClosure: (()->())?
private lazy var interactionOverlay: UIControl = {
let c = UIControl()
c.autoresizingMask = [.FlexibleHeight, .FlexibleWidth]
c.addTarget(self, action: "interactionOverlayAction", forControlEvents: .TouchDown)
return c
}()
func disableInteractionWhileEditingWithClosure(callback: ()->()) {
if disableInteractionClosure == nil {
disableInteractionClosure = callback
/* display control overlay */
interactionOverlay.frame = navigationController!.view.bounds
navigationController!.view.addSubview(interactionOverlay)
}
}
func interactionOverlayAction() {
disableInteractionClosure!()
}
func enableInteraction() {
disableInteractionClosure = nil
interactionOverlay.removeFromSuperview()
}
Basically the UIControl is used to block user from switching between contact while user is editing another contact/ creating a new one by blocking interaction with the contact list and if changes have been made on the editing/creating view it fires a method that shows a pop up saying "modifications have been made do you want to continue without saving ? cancel or continue " :
func cancel() {
self.view.endEditing(true)
let closure: ()->() = {
self.layoutView.willResign()
self.delegate?.tabDetailsControllerDidCancel(self)
}
if layoutView.hasChanges() {
MKAlertViewController().instantaneousAlert(title: "Modification apportées", message: "Êtes-vous sur de vouloir continuer sans sauvegarder les modifications ?", confirmButtonTitle: "Oui", cancelButtonTitle: "Annuler", confirmed: { () -> Void in
closure()
}, canceled: { () -> Void in
})
} else {
closure()
}
}
It works fine on iPad because the UIControl is only above the Master View and is enabled when in editing mode on the Detail View (iPad 3D Debugging view), so the pop up shows only when manually cancelling the editing/creating or when trying to change contact while editing/creating, but as the splitView don't work the same on iPads and iPhones and it appears that on iPhone the Master View is placed above the Detail View, the UIControl is also above (iPhone 3D Debugging view), and it causes to block interactions on all the screen and wherever I click the cancel pop-up is shown.
Can you guys explain me a way to enable/show this UIControl only when the MasterView is showing and not everywhere. Thanks.
I ended up using the viewWillDisappear on the detail view :
override func viewWillDisappear(animated: Bool) {
super.viewWillDisappear(animated)
if self.isMovingFromParentViewController() || self.isBeingDismissed() {
if editingMode {
shared.contactsListController.disableInteractionWhileEditingWithClosure({ (_) in
self.tabDetailsController.cancel()
})
shared.contactsListController.disableToolbar()
} else {
shared.contactsListController.enableInteraction()
shared.contactsListController.enableToolbar()
}
self.navigationController?.toolbar.alpha = 1
}
}
and modifying the disableInteractionWhileEditingWithClosure method on the master view:
func disableInteractionWhileEditingWithClosure(callback: ()->()) {
if disableInteractionClosure == nil {
disableInteractionClosure = callback
/* display control overlay */
interactionOverlay.frame = navigationController!.view.bounds
view.addSubview(interactionOverlay) // this line
}
}
and it works ! :)

Resources