實現iOS複雜聯動界面, 手勢衝突界面的一些思路

原創文章首發本人博客: blog.cocosdever.com/2019/09/03/…算法

文檔更新說明

  • 最後更新 2019年09月05日
  • 首次更新 2019年09月03日

前言

  最近有個需求, 要實現一個相似excel那樣的表格展現視圖, 視圖又要支持上下拉刷新功能同時還要支持整屏滾動功能, 其實說白了, 就是須要用到界面聯合滾動和解決手勢衝突問題. 本文就是想結合最近作的UI總結出這個能夠應用到其餘更復雜界面上的套路出來.app

關於手勢衝突這裏不是說UIGestureRecognizer的使用和他的代理UIGestureRecognizerDelegate提供的手勢衝突解決方法, 而是說一些其餘的, 下面再說.ide

例子

  就從我就近作的需求開始講起, 先簡單看一下界面的實際效果GIF.佈局

再複雜的界面, 無非都是由一些簡單的部件組成, 再結合手勢, 位置同步等手段讓它們協調工做起來, 以致於看起來就是一個總體. 這些簡單的界面大概就是有UIView, UIScrollView, UITableView, UICollectionView, 組合的時候就是多個UITableView互相嵌套, 或者是UIScrollView嵌套UITableView, 又或者是一個ScrollView放一側, 另外一個TableView放一側等方式. 本例的組件命名爲FMMachineListView性能

思路

  上面的界面能夠作以下分解:測試

先介紹一下這幾個基本視圖對應的功能:動畫

藍色綠色兩個視圖能夠用約束佈局或者代碼佈局, 確保他們按照必定比例分配便可. 若是把整個表格功能封裝成一個組件, 綠色視圖其實就是這個組件的根視圖.固然若是須要Interface Builder直接擺放視圖的話, 能夠先放一個普通的View, View裏再放組件根視圖便可, 靈活變通.ui

紅色視圖自己不必定要設置爲ScrollView, 可是爲了能兼容MJRefresh上下拉刷新組件, 我使用了更爲複雜的ScrollView來作, 這樣就能夠方便地往它身上加入上下拉視圖了. 紅色視圖的contentSize應該和綠色視圖同樣, 這樣能夠固定住白色視圖.編碼

灰色視圖是一個ScrolView, 主要目的是爲了讓表格除第一列以外其餘列能夠左右滾動, 這樣才能添加更多的列進來. 灰色視圖裏面的小矩形視圖, 我這裏爲了複雜起見, 每一列都是一個TableView, 不過若是改爲只有一個TableView, 而後在每個Cell裏去控制每一行的數據也是能夠的. 對了別忘了, 灰色視圖的contentSize高度應該等於紅色視圖, 由於他不須要上下滾動, 不過contentSize的寬度就要看具體有多少列, 這樣才能實現左右滾動.idea

實現視圖聯動

  爲了讓整個組件看起來就是一個總體, 好比每一列自己都是一個TableView, 那用戶滾動的時候確定只是滾動了其中一列, 這樣就須要作聯動, 把滾動的信息傳遞給其餘TableView, 這樣看起來纔不會像下面GIF這樣.   

因此說, 組合的界面, 聯動這個思路是比較常見的. 具體聯動怎麼實現, 放到下面說.

解決手勢衝突

  本例中, 主要的手勢衝突有如下幾個:    列表視圖和紅色視圖有衝突. 列表視圖須要支持上下滾動展現更多行, 而紅色視圖須要支持上下滾動來實現數據刷新功能, 咱們知道iOS中多個相同類型的手勢, 好比pan手勢, 默認只會響應其中一個, 因此列表視圖的pan手勢響應了以後紅色視圖的pan手勢不會觸發了. 注意這裏pan手勢都是scrollView自帶的, 不像開發者本身添加的手勢能夠經過UIGestureRecognizerDelegate解決多手勢響應問題.

列表視圖和藍色視圖的衝突, 藍色視圖也須要向上滾動到屏外, 因此也須要一個比較好的解決方案.

上面兩個問題的解決思路, 我以爲能夠這樣來作:

  1. 首先讓多個嵌套的scrollView(紅色, 灰白色: 灰色和白色的統稱)只有一個支持上下滾動, 這裏就有兩種選擇, 一種是讓灰白色都不支持上下滾動, 讓紅色支持上下滾動, 這樣滾動時響應的是紅色視圖的pan手勢.

  2. 不須要紅色支持上下滾動, 可是讓灰色白色支持上下滾動, 而後在灰白色滾動的同時, 根據必定算法判斷是否中止滾動灰白色, 用算法模擬滾動紅色(實際上用的仍是灰白色的pan手勢), 下面會有代碼具體演示一下.

  3. 藍色視圖這部分的衝突, 本例的需求其實能夠像上面設計圖那樣讓藍色視圖直接放到控制器的view上, 接着觀察列表組件的滾動狀況, 列表上拉到頂部了則藍色視圖用動畫滾到屏外, 列表組件從頂部下拉的時候, 藍色視圖滾回原位. 若是有須要, 還可讓藍色部分也支持響應滾動手勢的話, 能夠直接把白色視圖的pan手勢添加到控制器的view上, 這樣整個控制器都能響應pan手勢, 並且白色視圖又能正確滾動, 又能讓藍色視圖觀察到滾動狀況從而也就能夠在藍色視圖上響應滾動事件了. (若是是方案1那就是操做紅色視圖的pan手勢), 他的效果就像下面GIF演示的:   

各類方案的優缺點

  上面提到的解決手勢衝突的方案, 第三種嚴格說也算不上, 這裏主要就說1和2的優缺點.

方案1

優勢是實現比較簡單, ScrollView裏面嵌套TableView(或者其餘ScrollView子類視圖), 讓TableView把所有內容都顯示出來, 只須要簡單計算一下TableView有多少row,多少section以及具體的高度彙總就是整個TableView的內容高度了,這樣TableView的frame.size等於TableView的contentSize, 本質上就退化成一個普通的UIView, 滾動的是外部的ScrollView, 因此能夠解決手勢衝突.

缺點也比較明顯, TableView失去原有的複用機制, 每一次都把所有的Cell都加載出來放到內存裏, 把所有Cell的視圖都渲染出來, 佔用內存變大, 所以此方案只適合行數較少的場景, 我測試了一下大概1000行以後就會有很明顯的卡頓了.

方案2 優勢是性能正常, ScrollView裏面嵌套TableView, 內部的TableView自己仍是支持滾動的, 這也就支持了視圖的複用機制, 佔用內存少, 支持任意數據量.

缺點是編碼比方案1複雜, 由於須要在內部的TableView滾動到合適的位置的時(好比頂部或底部), 經過算法讓TableView的contentOffset固定住, 而後根據滾動的偏移量直接去設置外層ScrollView的contentOffset, 這樣就能夠實現看上去內部的TableView中止滾動了外部的ScrollView開始滾動的樣子, 這樣也能夠解決手勢衝突. 這部分下文會有代碼演示, 看不明白的能夠繼續往下看.

具體實現

界面聯動

  界面聯動的實現方式有多種, 本文主要是講思路, 因此這裏講最簡單的實現, 能看懂就好, 後面可能會再發一篇文章專門論述如何優雅實現界面聯合滾動.    首先是白色,灰色視圖這樣的列須要同步滾動, 那麼就在每個列對應的TableView子類裏定義一個協議, 這裏就叫FMSyncDelegate, 協議內容以下:

// FMDataTableView.h

@protocol FMSyncDelegate <NSObject>

- (void)dataTableView:(UITableView *)tableView contentOffSet:(CGPoint)contentOffSet;

- (void)dataTableView:(UITableView *)tableView didSelected:(NSIndexPath *)indexPath;

- (void)dataTableView:(UITableView *)tableView didDeSelected:(NSIndexPath *)indexPath;

- (void)dataTableViewDidEndDragging:(UITableView *)tableView;

- (void)dataTableViewBeganDragging:(UITableView *)tableView;

@end
複製代碼

具體功能就不用介紹了看名字已經很清晰了, 目的就是把tableView內部具體發生事情回調給實現了FMSyncDelegate協議的對象. 同步滾動的時候, 只須要把組件設置爲列表視圖的代理, 而後實現便可. 本例中整個協議的方法都要實現, 由於要支持點擊某一行跳進詳情頁, 也要知道手指何時觸摸列表何時離開列表好實現上下拉功能. 協議還須要支持其餘什麼功能這個具體看狀況而定便可.

下面再說一下同步滾動這個功能的簡單實現. 在組件對應的類裏(綠色視圖)實現列表的同步代理, 監聽到某一個列滾動時, 把信號也發給其餘列, 讓其餘列跟着滾動便可.

// FMMachineListView.h

- (void)dataTableView:(UITableView *)tableView contentOffSet:(CGPoint)contentOffSet {
    static BOOL stopSync = NO;
    if (stopSync == YES) {
        return;
    }
    stopSync = YES;
    
    FMDataTableView *headTV = (FMDataTableView *)_headView.subviews[0];    
    [headTV setTableViewContentOffSet:contentOffSet];
    
    for (UIView *subView in _scroll.subviews) {
        if ([subView isKindOfClass:[FMDataTableView class]]) {
            [(FMDataTableView *)subView setContentOffSet:contentOffSet];
        }
        if (subView == _scroll.subviews.lastObject) {
            // 本輪數據同步最後一個對象結束以後, 才容許下一輪同步, 這樣能夠避免重複的同步操做
            stopSync = NO;
        }
    }
}
複製代碼

其中stopSync是爲了防止重複同步, 畢竟這裏對每個tableView都調用了setContentOffSet:, 這樣就又會致使- (void)dataTableView:(UITableView *)tableView contentOffSet:(CGPoint)contentOffSet方法被觸發一次, 因此每一輪同步只有第一個事件能起做用, 其餘同一輪的事件都直接忽略, 直到最後一個列表被同步以後才容許新一輪同步.   

解決滾動衝突

  這裏只講方案2, 紅色視圖不須要響應上下滾動手勢, 灰白色須要. 下面給出滾動灰白色視圖, 固定灰白色視圖的位置滾動紅色視圖, 以及如何上下滑動藍色視圖的代碼.

// FMMachineListView.h

- (void)dataTableView:(UITableView *)tableView contentOffSet:(CGPoint)contentOffSet {
    static BOOL stopSync = NO;
    if (stopSync == YES) {
        return;
    }
    stopSync = YES;
    
    FMDataTableView *headTV = (FMDataTableView *)_headView.subviews[0];
    
    // 注意若是contentSize尚未tableview自己大的話, 說明數據太少了tableView都不須要滾動, 也就不須要加載下一頁了, 也不須要通知上下滾的事件.
    BOOL isLongPage = NO;
    if (headTV.contentSize.height > headTV.frame.size.height) {
        isLongPage = YES;
    }
    
     // 注意若是contentSize尚未tableview自己大的話, 說明數據太少了tableView都不須要滾動, 也就不須要加載下一頁了, 也不須要通知上下滾的事件.
    BOOL isLongPage = NO;
    if (headTV.contentSize.height > headTV.frame.size.height) {
        isLongPage = YES;
    }
        
    // 可以使用tableView.isDragging控制是否只在拉動狀態觸發
    if (isLongPage) {
        if (contentOffSet.y <= 0) {
            NSLog(@"向下滾動了");
            // 列表向下滾動(展現上面內容), 通知代理
            if ([self.delegate respondsToSelector:@selector(machineListScrollDown)]) {
                [self.delegate machineListScrollDown];
            }
        } else if (contentOffSet.y > 0) {
            NSLog(@"向上滾動了");
            // 列表向上滾動(展現下面內容)
            if ([self.delegate respondsToSelector:@selector(machineListScrollUp)]) {
                [self.delegate machineListScrollUp];
            }
        }
    }
    
    if (contentOffSet.y <= 0 && ((FMDataTableRefreshNormalHeader *)self.panelScrollView.mj_header).dragging == YES) {
        // 當列表滾動到頂部時, 讓contentOffset.y保持在0位置, 同時調整panelScrollView的contentOffset, 讓下拉刷新控件露出來
        // 同時要處理拖動列表以後鬆手的事件, 讓contentOffset復原!
        self.panelScrollView.contentOffset = CGPointMake(0, self.panelScrollView.contentOffset.y + (contentOffSet.y / 2));
        
        contentOffSet.y = 0;
    }
    
    
    // 處理滑動到底部直接加載下一頁數據
    // 滾動到底部時, y的座標恰好是contentSize高度減去視圖自己高度
    // 不過要注意若是contentSize尚未tableview自己大的話, 說明數據太少了tableView都不須要滾動, 也就不須要加載下一頁了
    if (isLongPage) {
        CGFloat happendY = headTV.contentSize.height - headTV.frame.size.height;
        if (contentOffSet.y >= happendY) {
            self.panelScrollView.contentOffset = CGPointMake(0, self.panelScrollView.contentOffset.y + ((contentOffSet.y - happendY) / 2));
            contentOffSet.y = happendY;
        }
    }
    
    [headTV setTableViewContentOffSet:contentOffSet];
    for (UIView *subView in _scroll.subviews){
        if ([subView isKindOfClass:[FMDataTableView class]]) {
            [(FMDataTableView *)subView setTableViewContentOffSet:contentOffSet];
        }
        if (subView == _scroll.subviews.lastObject) {
            // 本輪數據同步最後一個對象結束以後, 才容許下一輪同步, 這樣能夠避免重複的同步操做
            stopSync = NO;
        }
    }
}

- (void)dataTableViewDidEndDragging:(UITableView *)tableView {
    ((FMDataTableRefreshNormalHeader *)self.panelScrollView.mj_header).dragging = NO;
    if (self.panelScrollView.contentOffset.y != 0) {
        [self.panelScrollView setContentOffset:CGPointMake(0, 0) animated:YES];
    }
}

- (void)dataTableViewBeganDragging:(UITableView *)tableView {
    ((FMDataTableRefreshNormalHeader *)self.panelScrollView.mj_header).dragging = YES;
}


複製代碼

具體代碼做用看註釋, 其中panelScrollView就是本例的紅色視圖.

最後要說的就是如何讓藍色視圖也能滾動, 實現的原理就是讓控制器實現組件的協議, 協議內容以下:

// FMMachineListView.h

@protocol FMMachineListViewDelegate <NSObject>
@optional
- (void)machineListViewDidSelected:(NSIndexPath *)indexPath;

- (void)machineListViewDidLongPress:(NSIndexPath *)indexPath;

- (void)machineListScrollUp;

- (void)machineListScrollDown;
@end
複製代碼

其中machineListScrollUpmachineListScrollDown兩個方法會在組件從頂部上拉或者頂部下拉時被調用. 組件內部具體實現的代碼就是下面這段:

// FMMachineListView.h

    // 注意若是contentSize尚未tableview自己大的話, 說明數據太少了tableView都不須要滾動, 也就不須要加載下一頁了, 也不須要通知上下滾的事件.
    BOOL isLongPage = NO;
    if (headTV.contentSize.height > headTV.frame.size.height) {
        isLongPage = YES;
    }
        
    // 可以使用tableView.isDragging控制是否只在拉動狀態觸發
    if (isLongPage) {
        if (contentOffSet.y <= 0) {
            NSLog(@"向下滾動了");
            // 列表向下滾動(展現上面內容), 通知代理
            if ([self.delegate respondsToSelector:@selector(machineListScrollDown)]) {
                [self.delegate machineListScrollDown];
            }
        } else if (contentOffSet.y > 0) {
            NSLog(@"向上滾動了");
            // 列表向上滾動(展現下面內容)
            if ([self.delegate respondsToSelector:@selector(machineListScrollUp)]) {
                [self.delegate machineListScrollUp];
            }
        }
    }
複製代碼

控制器只要實現了machineListScrollUpmachineListScrollDown這兩個方法, 就能夠控制藍色視圖滾動的時機了, 最終效果就是本文開頭GIF演示那樣子.

總結

  至此就把本文開頭的例子的實現講完了. 本文實現一個沒法直接用系統自帶視圖實現的界面, 經過組合多個列表視圖和滾動視圖, 講述了這類需求的主要問題, 也就是聯合滾動, 手勢衝突這些, 並給出瞭解決方案, 目的就是但願能把複雜界面的實現思路理清楚, 無論遇到什麼樣的界面, 百變不離其宗, 只要稍加靈活變通便可實現, 性能也會太差 : )

相關文章
相關標籤/搜索