What is the most efficient way to find the lowest common ancestor between two UIView instances?
Short of implementing Lowest Common Ancestor, are there any UIKit APIs that can be leveraged to find it?
NSView has ancestorSharedWithView: so I suspect this might be added sooner than later to iOS.
I'm currently using this quick and dirty solution, which is inefficient if the given view isn't a sibling or direct ancestor.
- (UIView*)lyt_ancestorSharedWithView:(UIView*)aView
{
if (aView == nil) return nil;
if (self == aView) return self;
if (self == aView.superview) return self;
UIView *ancestor = [self.superview lyt_ancestorSharedWithView:aView];
if (ancestor) return ancestor;
return [self lyt_ancestorSharedWithView:aView.superview];
}
(for those implementing a similar method, the unit tests of the Lyt project might be helpful)
It's not too hard, using -isDescendantOfView:.
- (UIView *)my_ancestorSharedWithView:(UIView *)aView
{
UIView *testView = self;
while (testView && ![aView isDescendantOfView:testView])
{
testView = [testView superview];
}
return testView;
}
Swift 3:
extension UIView {
func findCommonSuperWith(_ view:UIView) -> UIView? {
var a:UIView? = self
var b:UIView? = view
var superSet = Set<UIView>()
while a != nil || b != nil {
if let aSuper = a {
if !superSet.contains(aSuper) { superSet.insert(aSuper) }
else { return aSuper }
}
if let bSuper = b {
if !superSet.contains(bSuper) { superSet.insert(bSuper) }
else { return bSuper }
}
a = a?.superview
b = b?.superview
}
return nil
}
}
A functional alternative:
Swift (assuming the use of your favourite OrderedSet)
extension UIView {
func nearestCommonSuperviewWith(other: UIView) -> UIView {
return self.viewHierarchy().intersect(other.self.viewHierarchy()).first
}
private func viewHierarchy() -> OrderedSet<UIView> {
return Set(UIView.hierarchyFor(self, accumulator: []))
}
static private func hierarchyFor(view: UIView?, accumulator: [UIView]) -> [UIView] {
guard let view = view else {
return accumulator
}
return UIView.hierarchyFor(view.superview, accumulator: accumulator + [view])
}
}
Objective-C (implemented as a category on UIView, assuming the existence of a firstObjectCommonWithArray method)
+ (NSArray *)hierarchyForView:(UIView *)view accumulator:(NSArray *)accumulator
{
if (!view) {
return accumulator;
}
else {
return [self.class hierarchyForView:view.superview accumulator:[accumulator arrayByAddingObject:view]];
}
}
- (NSArray *)viewHierarchy
{
return [self.class hierarchyForView:self accumulator:#[]];
}
- (UIView *)nearestCommonSuperviewWithOtherView:(UIView *)otherView
{
return [[self viewHierarchy] firstObjectCommonWithArray:[otherView viewHierarchy]];
}
Here's a little shorter version, as a category on UIView:
- (UIView *)nr_commonSuperview:(UIView *)otherView
{
NSMutableSet *views = [NSMutableSet set];
UIView *view = self;
do {
if (view != nil) {
if ([views member:view])
return view;
[views addObject:view];
view = view.superview;
}
if (otherView != nil) {
if ([views member:otherView])
return otherView;
[views addObject:otherView];
otherView = otherView.superview;
}
} while (view || otherView);
return nil;
}
Your implementation only check two view level in one iteration.
Here is mine:
+ (UIView *)commonSuperviewWith:(UIView *)view1 anotherView:(UIView *)view2 {
NSParameterAssert(view1);
NSParameterAssert(view2);
if (view1 == view2) return view1.superview;
// They are in diffrent window, so they wont have a common ancestor.
if (view1.window != view2.window) return nil;
// As we don’t know which view has a heigher level in view hierarchy,
// We will add these view and their superview to an array.
NSMutableArray *mergedViewHierarchy = [#[ view1, view2 ] mutableCopy];
UIView *commonSuperview = nil;
// Loop until all superviews are included in this array or find a view’s superview in this array.
NSInteger checkIndex = 0;
UIView *checkingView = nil;
while (checkIndex < mergedViewHierarchy.count && !commonSuperview) {
checkingView = mergedViewHierarchy[checkIndex++];
UIView *superview = checkingView.superview;
if ([mergedViewHierarchy containsObject:superview]) {
commonSuperview = superview;
}
else if (checkingView.superview) {
[mergedViewHierarchy addObject:superview];
}
}
return commonSuperview;
}
Mine is a bit longer and without using UIKit isDescendant function.
Method 1: With a method of finding LCA in trees. Time complexity:O(N), Space complexity: (1)
func findCommonSuper(_ view1:inout UIView, _ view2:inout UIView) -> UIView? {
var level1 = findLevel(view1)
var level2 = findLevel(view2)
if level1 > level2 {
var dif = level1-level2
while dif > 0 {
view1 = view1.superview!
dif -= 1
}
} else if level1 < level2 {
var dif = level2-level1
while dif > 0 {
view2 = view2.superview!
dif -= 1
}
}
while view1 != view2 {
if view1.superview == nil || view2.superview == nil {
return nil
}
view1 = view1.superview!
view2 = view2.superview!
}
if view1 == view2 {
return view1
}
return nil
}
func findLevel(_ view:UIView) -> Int {
var level = 0
var view = view
while view.superview != nil {
view = view.superview!
level += 1
}
return level
}
Method 2: Inserting one view's ancestors to set and then iterating second ones ancestors. Time complexity: O(N), Space complexity: O(N)
func findCommonSuper2(_ view1:UIView, _ view2:UIView) -> UIView? {
var set = Set<UIView>()
var view = view1
while true {
set.insert(view)
if view.superview != nil {
view = view.superview!
} else {
break
}
}
view = view2
while true {
if set.contains(view) {
return view
}
if view.superview != nil {
view = view.superview!
} else {
break
}
}
return nil
}
Swift 2.0:
let view1: UIView!
let view2: UIView!
let sharedSuperView = view1.getSharedSuperview(withOtherView: view2)
/**
* A set of helpful methods to find shared superview for two given views
*
* #author Alexander Volkov
* #version 1.0
*/
extension UIView {
/**
Get nearest shared superview for given and otherView
- parameter otherView: the other view
*/
func getSharedSuperview(withOtherView otherView: UIView) {
(self.getViewHierarchy() as NSArray).firstObjectCommonWithArray(otherView.getViewHierarchy())
}
/**
Get array of views in given view hierarchy
- parameter view: the view whose hierarchy need to get
- parameter accumulator: the array to accumulate views in
- returns: the list of views from given up to the top most view
*/
class func getHierarchyForView(view: UIView?, var accumulator: [UIView]) -> [UIView] {
if let superview = view?.superview {
accumulator.append(view!)
return UIView.getHierarchyForView(superview, accumulator: accumulator)
}
return accumulator
}
/**
Get array of views in the hierarchy of the current view
- returns: the list of views from cuurent up to the top most view
*/
func getViewHierarchy() -> [UIView] {
return UIView.getHierarchyForView(self, accumulator: [])
}
}
Swift 5 version of Carl Lindberg's solution:
func nearestCommonSuperviewWith(other: UIView) -> UIView? {
var nearestAncestor: UIView? = self
while let testView = nearestAncestor, !other.isDescendant(of: testView) {
nearestAncestor = testView.superview
}
return nearestAncestor
}
Related
I have a view which has more than 15 UITextFields. I have to set bottomBorder(extension) for all the UITextFields. I can set it one by one for all the UITextFields and its working too. I want to set the bottom border for all the UITextFields at once. Here is the code I am trying but it seems like that for loop is not executing. I have even tried it in viewDidLayoutSubViews but for loop not executing there too.
override func viewDidLoad()
{
super.viewDidLoad()
/** setting bottom border of textfield**/
for case let textField as UITextField in self.view.subviews {
textField.setBottomBorder()
}
}
Swift: This function will return all text-fields in a view. No matter if field exists in any subview. ;-)
func getAllTextFields(fromView view: UIView)-> [UITextField] {
return view.subviews.flatMap { (view) -> [UITextField] in
if view is UITextField {
return [(view as! UITextField)]
} else {
return getAllTextFields(fromView: view)
}
}.flatMap({$0})
}
Usage:
getAllTextFields(fromView : self.view).forEach{($0.text = "Hey dude!")}
Generic Way:
func getAllSubviews<T: UIView>(fromView view: UIView)-> [T] {
return view.subviews.map { (view) -> [T] in
if let view = view as? T {
return [view]
} else {
return getAllSubviews(fromView: view)
}
}.flatMap({$0})
}
Usage:
let textFields: [UITextField] = getAllSubviews(fromView: self.view)
I made it working, but still need the explanation why the code in question is not working
I got it from somewhere on the forum, not exactle able to credit the answer.
/** extract all the textfield from view **/
func getTextfield(view: UIView) -> [UITextField] {
var results = [UITextField]()
for subview in view.subviews as [UIView] {
if let textField = subview as? UITextField {
results += [textField]
} else {
results += getTextfield(view: subview)
}
}
return results
Call the above function in viewDidLoad or viewDidLayoutSubviews.
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
/** setting bottom border to the textfield **/
let allTextField = getTextfield(view: self.view)
for txtField in allTextField
{
txtField.setBottomBorder()
}
}
extension:
extension UIView {
func viewOfType<T:UIView>(type:T.Type, process: (_ view:T) -> Void)
{
if let view = self as? T
{
process(view)
}
else {
for subView in subviews
{
subView.viewOfType(type:type, process:process)
}
}
}
}
Usage:
view.viewOfType(type:UITextField.self) {
view in
view.text = "123"
}
try this
for aSubView: Any in self.view.subviews {
if (aSubView is UITextField) {
var textField = (aSubView as! UITextField)
textField. setBottomBorder()
}
}
or try this
for view in self.view.subviews {
if (view is UITextField) {
var textField = view as! UITextField
textField. setBottomBorder()
}
}
Try this :)
for view in self.view.subviews as! [UIView] {
if let textField = view as? UITextField {
textField.setBottomBorder()
}
}
This worked for me.
var textFieldsArray = [UITextField]()
for view in self.view.subviews {
if view is UITextField {
textFieldsArray.append(view as! UITextField)
}
}
textFieldsArray.forEach { $0.setBottomBorder() }
If you want to get the result of the function applied in a new array, use map() instead.
func getTextFields() {
for textField in view.subviews where view is UITextField {
(textField as? UITextField).setBottomBorder()
}
}
Swift 5
A Very simple answer you can understand easyly
: - You can handle all kind of Objects like UILable, UITextfields, UIButtons, UIView, UIImages . any kind of objecs etc.
for subviews in self.view.subviews {
if subviews is UITextField
{
//MARK: - if the sub view is UITextField you can handle here
funtextfieldsetting(textfield: subviews as! UITextField)
}
if subviews is UIButton
{
//MARK: - if the sub view is UIButton you can handle here
funbuttonsetting(button: subviews as! UIButton)
}
if subviews is UILabel
{
//MARK: - if the sub view is UILabel you can handle here
//Any thing you can do it with label or textfield etc
}
}
My designer is asking that I display 3 dots in a UIPageViewController for 10 views.
When the first 3 view controllers display, the 0th dot should be highlighted; when the next 4 view controllers display, the 1st dot should be highlighted; when the final 3 view controllers display, the 2nd dot should be highlighted.
So far I'm able to display 3 dots in the UIPageControl, but the indicator dot just rotates around indicating the n%3 position as active.
func presentationCountForPageViewController(pageViewController: UIPageViewController) -> Int {
return 3
}
I haven't seen any documentation on how to control with UIPageControl index is the active index, so I'm not sure if this is something Apple wants you to be able to override.
If there is a way to accomplish what I'm trying to do, I'd appreciate the help.
It turns out that what I'm trying to accomplish can't be done with a UIPageViewController. By default the UIPageControl in this class cannot be overridden directly.
Instead, I was able to use a combination of a UICollectionView (with a hack that allows it to resemble a UIPageViewController in its page changing effects) and a UIPageControl, as subviews to the same overarching UIViewController.
class MyPageViewController : UIViewController {
// MARK: subviews
private var collectionView:UICollectionView!
/// the collection layout controls the scrolling behavior of the collection view
private var collectionLayout = MyLayout()
private var pageControl = UIPageControl()
let CollectionViewCellReuseIdentifer = "CollectionViewCellReuseIdentifier"
// MARK: autolayout
private var autolayoutConstraints:[NSLayoutConstraint] = [NSLayoutConstraint]()
// MARK: constructors
init() {
super.init(nibName: nil, bundle: nil)
}
// MARK: UIViewController lifecycle methods
override func viewDidLoad() {
super.viewDidLoad()
self.setupView()
}
/**
Set up the collection view, page control, skip & log in buttons
*/
func setupView() {
self.setupCollectionView()
self.setupPageControl()
self.setupConstraints()
self.view.addConstraints(self.autolayoutConstraints)
}
/**
Set up the collection view
*/
func setupCollectionView() {
self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.collectionLayout)
self.collectionView.translatesAutoresizingMaskIntoConstraints = false
self.collectionView.registerClass(MyPageView.self, forCellWithReuseIdentifier: self.CollectionViewCellReuseIdentifer)
self.collectionView.dataSource = self
self.collectionView.delegate = self
self.collectionView.backgroundColor = UIColor.whiteColor()
self.collectionView.scrollEnabled = true
self.collectionView.decelerationRate = UIScrollViewDecelerationRateFast;
self.collectionLayout.minimumInteritemSpacing = 1
self.collectionLayout.minimumLineSpacing = 1
self.collectionLayout.scrollDirection = .Horizontal
self.collectionLayout.delegate = self
self.view.addSubview(self.collectionView)
}
/**
Set up view showing pagination dots for slideshow items
*/
func setupPageControl() {
self.pageControl.translatesAutoresizingMaskIntoConstraints = false
self.pageControl.numberOfPages = 3
self.pageControl.backgroundColor = UIColor.whiteColor()
self.view.addSubview(self.pageControl)
}
func setupConstraints() {
let views:[String:AnyObject] = [
"collectionView" : self.collectionView,
"pageControl" : self.pageControl,
]
self.autolayoutConstraints.appendContentsOf(
NSLayoutConstraint.constraintsWithVisualFormat(
"V:|[collectionView][pageControl]|",
options: .AlignAllCenterX,
metrics: nil,
views: views
)
)
self.autolayoutConstraints.appendContentsOf(
NSLayoutConstraint.constraintsWithVisualFormat(
"H:|[collectionView]|",
options: .AlignAllCenterY,
metrics: nil,
views: views
)
)
self.autolayoutConstraints.appendContentsOf(
NSLayoutConstraint.constraintsWithVisualFormat(
"H:|[pageControl]|",
options: NSLayoutFormatOptions(),
metrics: nil,
views: views
)
)
}
}
extension MyPageViewController : MyPageViewControllerDelegate {
func didSwitchToPage(imageIndex: Int) {
if imageIndex < 3 {
self.pageControl.currentPage = 0
} else if imageIndex < 7 {
self.pageControl.currentPage = 1
} else {
self.pageControl.currentPage = 2
}
self.pageControl.setNeedsDisplay()
}
}
The layout class was derived from an answer my coworker found when researching a similar issue. http://karmadust.com/centered-paging-with-preview-cells-on-uicollectionview/
/**
* Delegate for slide interactions
*/
protocol MyPageViewControllerDelegate {
/**
Triggered when a new page has been 'snapped' into place
- parameter imageIndex: index of the image that has been snapped to
*/
func didSwitchToPage(imageIndex: Int)
}
class MyLayout : UICollectionViewFlowLayout {
var delegate:MyPageViewControllerDelegate?
/*
Allows different items in the collection to 'snap' onto the current screen section.
Based off of http://karmadust.com/centered-paging-with-preview-cells-on-uicollectionview/
*/
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
if let cv = self.collectionView {
let cvBounds = cv.bounds
let halfWidth = cvBounds.size.width * 0.5;
let proposedContentOffsetCenterX = proposedContentOffset.x + halfWidth;
if let attributesForVisibleCells = self.layoutAttributesForElementsInRect(cvBounds) {
var candidateAttributes : UICollectionViewLayoutAttributes?
// the index of the image selected
var index:Int = 0
for attributes in attributesForVisibleCells {
// == Skip comparison with non-cell items (headers and footers) == //
if attributes.representedElementCategory != UICollectionElementCategory.Cell {
index++
continue
}
if let candAttrs = candidateAttributes {
let a = attributes.center.x - proposedContentOffsetCenterX
let b = candAttrs.center.x - proposedContentOffsetCenterX
if fabsf(Float(a)) < fabsf(Float(b)) {
candidateAttributes = attributes;
}
}
else { // == First time in the loop == //
candidateAttributes = attributes;
index++
continue;
}
}
// Beautification step , I don't know why it works!
if(proposedContentOffset.x == -(cv.contentInset.left)) {
return proposedContentOffset
}
if let delegate = self.delegate {
delegate.didSwitchToPage((candidateAttributes?.indexPath.row)!)
}
return CGPoint(x: floor(candidateAttributes!.center.x - halfWidth), y: proposedContentOffset.y)
}
}
// fallback
return super.targetContentOffsetForProposedContentOffset(proposedContentOffset)
}
}
Note: I trimmed down the actual code I used and replaced a bunch of names to make them more appropriate for examples. I did not run this specific code and did not test for errors in my IDE. That being said, the approach behind the code is solid.
I am trying to find the top constraint of the view in code.
The top constraint is added in storyboard, and I don't want to use an IBOutlet.
Logging the value of the firstAttribute in the following code seems to always return a constraint of type NSLayoutAttributeHeight. Any idea how I could reliably find a top constraint of a view in code?
NSLayoutConstraint *topConstraint;
for (NSLayoutConstraint *constraint in self.constraints) {
if (constraint.firstAttribute == NSLayoutAttributeTop) {
topConstraint = constraint;
break;
}
}
Instead of iterating through self.constraints, you should iterate through self.superview.constraints.
The self.constraints only contain constraints related to just the view (e.g. height and width constraints).
Here's a code example of what this might look like:
- (void)awakeFromNib
{
[super awakeFromNib];
if (!self.topConstraint) {
[self findTopConstraint];
}
}
- (void)findTopConstraint
{
for (NSLayoutConstraint *constraint in self.superview.constraints) {
if ([self isTopConstraint:constraint]) {
self.topConstraint = constraint;
break;
}
}
}
- (BOOL)isTopConstraint:(NSLayoutConstraint *)constraint
{
return [self firstItemMatchesTopConstraint:constraint] ||
[self secondItemMatchesTopConstraint:constraint];
}
- (BOOL)firstItemMatchesTopConstraint:(NSLayoutConstraint *)constraint
{
return constraint.firstItem == self && constraint.firstAttribute == NSLayoutAttributeTop;
}
- (BOOL)secondItemMatchesTopConstraint:(NSLayoutConstraint *)constraint
{
return constraint.secondItem == self && constraint.secondAttribute == NSLayoutAttributeTop;
}
I usually set an identifier of a required constraint in the IB and then find it in the code like this (Swift):
if let index = constraints.index(where: { $0.identifier == "checkmarkLeftMargin" }) {
checkmarkImageViewLeftMargin = constraints[index]
}
OR by #Tim Vermeulen
checkmarkImageViewLeftMargin = constraints.first { $0.identifier == "checkmarkLeftMargin" }
Using swift and UIView extension
extension UIView {
func findConstraint(layoutAttribute: NSLayoutAttribute) -> NSLayoutConstraint? {
if let constraints = superview?.constraints {
for constraint in constraints where itemMatch(constraint: constraint, layoutAttribute: layoutAttribute) {
return constraint
}
}
return nil
}
func itemMatch(constraint: NSLayoutConstraint, layoutAttribute: NSLayoutAttribute) -> Bool {
if let firstItem = constraint.firstItem as? UIView, let secondItem = constraint.secondItem as? UIView {
let firstItemMatch = firstItem == self && constraint.firstAttribute == layoutAttribute
let secondItemMatch = secondItem == self && constraint.secondAttribute == layoutAttribute
return firstItemMatch || secondItemMatch
}
return false
}
}
Based on #Igor answer, I changed a bit itemMatch method to consider when first item or second item is not a UIView. For example when constraint a UIView top to safe area top.
extension UIView {
func findConstraint(layoutAttribute: NSLayoutConstraint.Attribute) -> NSLayoutConstraint? {
if let constraints = superview?.constraints {
for constraint in constraints where itemMatch(constraint: constraint, layoutAttribute: layoutAttribute) {
return constraint
}
}
return nil
}
func itemMatch(constraint: NSLayoutConstraint, layoutAttribute: NSLayoutConstraint.Attribute) -> Bool {
let firstItemMatch = constraint.firstItem as? UIView == self && constraint.firstAttribute == layoutAttribute
let secondItemMatch = constraint.secondItem as? UIView == self && constraint.secondAttribute == layoutAttribute
return firstItemMatch || secondItemMatch
}
}
Set the identifier in the inspector in Xcode. That's what it's for. You name it.
If that's not enough you create the IBOutlet.
I write a small extension in Swift:
extension UIButton {
var topConstraints: [NSLayoutConstraint]? {
return self.constraints.filter( { ($0.firstItem as? UIButton == self && $0.firstAttribute == .top) || ($0.secondItem as? UIButton == self && $0.secondAttribute == .top) })
}
}
Here's a one-liner extension method, based on #Igor's approach:
extension UIView{
func constraint(for layoutAttribute: NSLayoutConstraint.Attribute) -> NSLayoutConstraint? {
return superview?.constraints.first { itemMatch(constraint: $0, layoutAttribute: layoutAttribute) }
}
private func itemMatch(constraint: NSLayoutConstraint, layoutAttribute: NSLayoutConstraint.Attribute) -> Bool {
if let firstItem = constraint.firstItem as? UIView, let secondItem = constraint.secondItem as? UIView {
let firstItemMatch = firstItem == self && constraint.firstAttribute == layoutAttribute
let secondItemMatch = secondItem == self && constraint.secondAttribute == layoutAttribute
return firstItemMatch || secondItemMatch
}
return false
}
}
[ I also made the method signature style to match Swift 3...5 style e.g.:
constraint(for:) instead of findConstraint(layoutAttribute:).
]
I am trying to implement similar code to this in a Swift project
https://gist.github.com/joaofranca/3159618
I am having difficulty getting the class for the subview in the NSStringFromClass sections.
I have tried NSStringFromClass(subview.class) but Swift doesn't like it.
Do you know how to use this in Swift?
Thanks,
Andy
Update:
You can call classForCoder on classes derived from NSObject:
var s: NSObject = "hello"
var i: NSObject = 3
NSStringFromClass(s.classForCoder) // "NSString"
NSStringFromClass(i.classForCoder) // "NSNumber"
Original answer:
In Swift, instead of identifying a class by name, use is:
Objective-C:
for (UIView *subview in self.subviews) {
if([NSStringFromClass([subview class]) isEqualToString:#"UITableViewCellDeleteConfirmationControl"]) {
// do magic here
...
}else if ([NSStringFromClass([subview class]) isEqualToString:#"UITableViewCellEditControl"]) {
// do magic here
...
}else if ([NSStringFromClass([subview class]) isEqualToString:#"UITableViewCellReorderControl"]) {
// do magic here
Swift:
for subview in self.subviews as [UIView] {
if subview is UITableViewCellDeleteConfirmationControl {
// do magic here
...
} else if subview is UITableViewCellEditControl {
// do magic here
...
} else if subview is UITableViewCellReorderControl {
// do magic here
Swift 2.0 ->
Override the layoutSubviews()
class MyCustomCell: UITableViewCell {
override func aSubView() {
super.layoutSubviews()
for aSubView in self.subviews {
if String(aSubView.classForCoder).rangeOfString("UITableViewCellDeleteConfirmationView") != nil {
// Do whatever you want to do with default Delete Button.
// aSubView is the Delete Button.
aSubView.frame = CGRectMake(aSubView.frame.origin.x, aSubView.frame.origin.y, aSubView.frame.size.width, aSubView.frame.size.height - 10)
}
}
}
}
swift 3.0 -> using constraints.
override func layoutSubviews() {
super.layoutSubviews()
for aSubView in self.subviews as [UIView] {
if String(describing: aSubView.classForCoder).range(of: "UITableViewCellDeleteConfirmationView") != nil {
aSubView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
aSubView.widthAnchor.constraint(equalToConstant: 50),
aSubView.heightAnchor.constraint(equalToConstant: 50),
])
}
}
}
In my case parent UIViewController contains UIPageViewController which contains UINavigationController which contains UIViewController. I need to add a swipe gesture to the last view controller, but swipes are handled as if they belong to page view controller. I tried to do this both programmatically and via xib but with no result.
So as I understand I can't achieve my goal until UIPageViewController handles its gestures. How to solve this issue?
The documented way to prevent the UIPageViewController from scrolling is to not assign the dataSource property. If you assign the data source it will move into 'gesture-based' navigation mode which is what you're trying to prevent.
Without a data source you manually provide view controllers when you want to with setViewControllers:direction:animated:completion method and it will move between view controllers on demand.
The above can be deduced from Apple's documentation of UIPageViewController (Overview, second paragraph):
To support gesture-based navigation, you must provide your view controllers using a data source object.
for (UIScrollView *view in self.pageViewController.view.subviews) {
if ([view isKindOfClass:[UIScrollView class]]) {
view.scrollEnabled = NO;
}
}
I translate answer of user2159978 to Swift 5.1
func removeSwipeGesture(){
for view in self.pageViewController!.view.subviews {
if let subView = view as? UIScrollView {
subView.isScrollEnabled = false
}
}
}
Implementing #lee's (#user2159978's) solution as an extension:
extension UIPageViewController {
var isPagingEnabled: Bool {
get {
var isEnabled: Bool = true
for view in view.subviews {
if let subView = view as? UIScrollView {
isEnabled = subView.isScrollEnabled
}
}
return isEnabled
}
set {
for view in view.subviews {
if let subView = view as? UIScrollView {
subView.isScrollEnabled = newValue
}
}
}
}
}
Usage: (in UIPageViewController)
self.isPagingEnabled = false
I've been fighting this for a while now and thought I should post my solution, following on from Jessedc's answer; removing the PageViewController's datasource.
I added this to my PgeViewController class (linked to my page view controller in the storyboard, inherits both UIPageViewController and UIPageViewControllerDataSource):
static func enable(enable: Bool){
let appDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
let pageViewController = appDelegate.window!.rootViewController as! PgeViewController
if (enable){
pageViewController.dataSource = pageViewController
}else{
pageViewController.dataSource = nil
}
}
This can then be called when each sub view appears (in this case to disable it);
override func viewDidAppear(animated: Bool) {
PgeViewController.enable(false)
}
I hope this helps someone out, its not as clean as I would like it but doesn't feel too hacky etc.
EDIT: If someone wants to translate this into Objective-C please do :)
Edit: this answer works for page curl style only. Jessedc's answer is far better: works regardless of the style and relies on documented behavior.
UIPageViewController exposes its array of gesture recognizers, which you could use to disable them:
// myPageViewController is your UIPageViewController instance
for (UIGestureRecognizer *recognizer in myPageViewController.gestureRecognizers) {
recognizer.enabled = NO;
}
A useful extension of UIPageViewController to enable and disable swipe.
extension UIPageViewController {
func enableSwipeGesture() {
for view in self.view.subviews {
if let subView = view as? UIScrollView {
subView.isScrollEnabled = true
}
}
}
func disableSwipeGesture() {
for view in self.view.subviews {
if let subView = view as? UIScrollView {
subView.isScrollEnabled = false
}
}
}
}
If you want your UIPageViewController to maintain it's ability to swipe, while allowing your content controls to use their features (Swipe to delete, etc), just turn off canCancelContentTouches in the UIPageViewController.
Put this in your UIPageViewController's viewDidLoad func. (Swift)
if let myView = view?.subviews.first as? UIScrollView {
myView.canCancelContentTouches = false
}
The UIPageViewController has an auto-generated subview that handles the gestures. We can prevent these subviews from cancelling content gestures.
From...
Swipe to delete on a tableView that is inside a pageViewController
Swifty way for #lee answer
extension UIPageViewController {
var isPagingEnabled: Bool {
get {
return scrollView?.isScrollEnabled ?? false
}
set {
scrollView?.isScrollEnabled = newValue
}
}
var scrollView: UIScrollView? {
return view.subviews.first(where: { $0 is UIScrollView }) as? UIScrollView
}
}
I solved it like this (Swift 4.1)
if let scrollView = self.view.subviews.filter({$0.isKind(of: UIScrollView.self)}).first as? UIScrollView {
scrollView.isScrollEnabled = false
}
Here is my solution in swift
extension UIPageViewController {
var isScrollEnabled: Bool {
set {
(self.view.subviews.first(where: { $0 is UIScrollView }) as? UIScrollView)?.isScrollEnabled = newValue
}
get {
return (self.view.subviews.first(where: { $0 is UIScrollView }) as? UIScrollView)?.isScrollEnabled ?? true
}
}
}
pageViewController.view.isUserInteractionEnabled = false
This will disable all interaction with the pages. If you need to user to be able to interact with the content - this is not the solution for you.
There's a much simpler approach than most answers here suggest, which is to return nil in the viewControllerBefore and viewControllerAfter dataSource callbacks.
This disables the scrolling gesture on iOS 11+ devices, while keeping the possibility to use the dataSource (for things such as the presentationIndex / presentationCount used for the page indicator)
It also disables navigation via. the pageControl (the dots in the bottom) for iOS 11-13. On iOS 14, the bottom dots navigation can be disabled using a UIAppearance proxy.
extension MyPageViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
return nil
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
return nil
}
}
Similar to #user3568340 answer
Swift 4
private var _enabled = true
public var enabled:Bool {
set {
if _enabled != newValue {
_enabled = newValue
if _enabled {
dataSource = self
}
else{
dataSource = nil
}
}
}
get {
return _enabled
}
}
Translating #user2159978's response to C#:
foreach (var view in pageViewController.View.Subviews){
var subView = view as UIScrollView;
if (subView != null){
subView.ScrollEnabled = enabled;
}
}
Thanks to #user2159978's answer.
I make it a little more understandable.
- (void)disableScroll{
for (UIView *view in self.pageViewController.view.subviews) {
if ([view isKindOfClass:[UIScrollView class]]) {
UIScrollView * aView = (UIScrollView *)view;
aView.scrollEnabled = NO;
}
}
}
(Swift 4) You can remove gestureRecognizers of your pageViewController:
pageViewController.view.gestureRecognizers?.forEach({ (gesture) in
pageViewController.view.removeGestureRecognizer(gesture)
})
If you prefer in extension:
extension UIViewController{
func removeGestureRecognizers(){
view.gestureRecognizers?.forEach({ (gesture) in
view.removeGestureRecognizer(gesture)
})
}
}
and pageViewController.removeGestureRecognizers
Declare it like this:
private var scrollView: UIScrollView? {
return pageViewController.view.subviews.compactMap { $0 as? UIScrollView }.first
}
Then use it like this:
scrollView?.isScrollEnabled = true //false
The answers I found look very confusing or incomplete to me so here is a complete and configurable solution:
Step 1:
Give each of your PVC elements the responsibility to tell whether left and right scrolling are enabled or not.
protocol PageViewControllerElement: class {
var isLeftScrollEnabled: Bool { get }
var isRightScrollEnabled: Bool { get }
}
extension PageViewControllerElement {
// scroll is enabled in both directions by default
var isLeftScrollEnabled: Bool {
get {
return true
}
}
var isRightScrollEnabled: Bool {
get {
return true
}
}
}
Each of your PVC view controllers should implement the above protocol.
Step 2:
In your PVC controllers, disable the scroll if needed:
extension SomeViewController: PageViewControllerElement {
var isRightScrollEnabled: Bool {
get {
return false
}
}
}
class SomeViewController: UIViewController {
// ...
}
Step 3:
Add the effective scroll lock methods to your PVC:
class PVC: UIPageViewController, UIPageViewDelegate {
private var isLeftScrollEnabled = true
private var isRightScrollEnabled = true
// ...
override func viewDidLoad() {
super.viewDidLoad()
// ...
self.delegate = self
self.scrollView?.delegate = self
}
}
extension PVC: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("contentOffset = \(scrollView.contentOffset.x)")
if !self.isLeftScrollEnabled {
disableLeftScroll(scrollView)
}
if !self.isRightScrollEnabled {
disableRightScroll(scrollView)
}
}
private func disableLeftScroll(_ scrollView: UIScrollView) {
let screenWidth = UIScreen.main.bounds.width
if scrollView.contentOffset.x < screenWidth {
scrollView.setContentOffset(CGPoint(x: screenWidth, y: 0), animated: false)
}
}
private func disableRightScroll(_ scrollView: UIScrollView) {
let screenWidth = UIScreen.main.bounds.width
if scrollView.contentOffset.x > screenWidth {
scrollView.setContentOffset(CGPoint(x: screenWidth, y: 0), animated: false)
}
}
}
extension UIPageViewController {
var scrollView: UIScrollView? {
return view.subviews.filter { $0 is UIScrollView }.first as? UIScrollView
}
}
Step 4:
Update scroll related attributes when reaching a new screen (if you transition to some screen manually don't forget to call the enableScroll method):
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
let pageContentViewController = pageViewController.viewControllers![0]
// ...
self.enableScroll(for: pageContentViewController)
}
private func enableScroll(for viewController: UIViewController) {
guard let viewController = viewController as? PageViewControllerElement else {
self.isLeftScrollEnabled = true
self.isRightScrollEnabled = true
return
}
self.isLeftScrollEnabled = viewController.isLeftScrollEnabled
self.isRightScrollEnabled = viewController.isRightScrollEnabled
if !self.isLeftScrollEnabled {
print("Left Scroll Disabled")
}
if !self.isRightScrollEnabled {
print("Right Scroll Disabled")
}
}
More efficient way with a return, call this method on viewdidload (Swift 5):
private func removeSwipeGesture() {
self.pageViewController?.view.subviews.forEach({ view in
if let subView = view as? UIScrollView {
subView.isScrollEnabled = false
return
}
})
}
You can implement the UIPageViewControllerDataSource protocol and return nil for the previousViewController and nextViewController methods. This will prevent the UIPageViewController from being able to swipe to the next or previous page.
fileprivate func canSwipeToNextViewController() -> Bool {
guard
currentIndex < controllers.count,
let controller = controllers[currentIndex] as? OnboardingBaseViewController,
controller.canSwipeToNextScreen
else {
return false
}
return true
}
}
// MARK: - UIPageViewControllerDataSource
extension ViewController: UIPageViewControllerDataSource {
func presentationCount(for pageViewController: UIPageViewController) -> Int {
controllers.count
}
func presentationIndex(for pageViewController: UIPageViewController) -> Int {
currentIndex
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerBefore viewController: UIViewController
) -> UIViewController? {
if let index = controllers.firstIndex(of: viewController) {
if index > 0 {
currentIndex -= 1
return controllers[index - 1]
} else {
// Return nil to prevent swiping to the previous page
return nil
}
}
return nil
}
func pageViewController(
_ pageViewController: UIPageViewController,
viewControllerAfter viewController: UIViewController
) -> UIViewController? {
if let index = controllers.firstIndex(of: viewController) {
if index < controllers.count - 1,
canSwipeToNextViewController() {
currentIndex += 1
return controllers[index + 1]
} else {
// Return nil to prevent swiping to the next page
return nil
}
}
return nil
}
}
Remember to set the dataSource property of the UIPageViewController to the view controller that implements the UIPageViewControllerDataSource protocol.
I hope that helps.
Enumerating the subviews to find the scrollView of a UIPageViewController didn't work for me, as I can't find any scrollView in my page controller subclass. So what I thought of doing is to disable the gesture recognizers, but careful enough to not disable the necessary ones.
So I came up with this:
if let panGesture = self.gestureRecognizers.filter({$0.isKind(of: UIPanGestureRecognizer.self)}).first
panGesture.isEnabled = false
}
Put that inside the viewDidLoad() and you're all set!
override func viewDidLayoutSubviews() {
for View in self.view.subviews{
if View.isKind(of: UIScrollView.self){
let ScrollV = View as! UIScrollView
ScrollV.isScrollEnabled = false
}
}
}
Add this in your pageviewcontroller class. 100% working
just add this control property at your UIPageViewController subclass:
var isScrollEnabled = true {
didSet {
for case let scrollView as UIScrollView in view.subviews {
scrollView.isScrollEnabled = isScrollEnabled
}
}
}