[iOS] [OC] 輕量級的表單框架 GSForm(附demo)

席萍萍BO發表於2018-09-17

1.主要特點

輕量級,只有4個類,1個控制器Controller,3個檢視模型ViewModel 支援** iOS8 及以上 **

GitHub 和 Demo 下載

  1. 支援完全自定義單元格cell型別
  • 支援自動佈局Autolayout和固定行高
  • 表單每行row資料和事件整合為一個model,基本只需管理row
  • 積木式組合 row,支援 sectionrow 的隱藏,易於維護
  • 支援傳入外部資料
  • 支援快速提取資料
  • 支援引數的最終合法性校驗
  • 支援資料模型的型別完全自由自定義,可拆可合
  • 支援設定row的白名單和黑名單及許可權管理

2.背景

通常,將一個頁面需要編輯/錄入多項資訊的頁面稱為“表單頁面”,以下稱表單,以某註冊頁面為例:

在移動端進行表單的錄入設計本身因為錄入效率低,是儘量避免的,但對於特定的業務場景還是有存在的情況。通常基於 UITableView 進行開發,內容多有文字輸入、日期(或者其他PickerView)、各類自定義的單元格cell(比如包含 UISwitch、UIStepper等)、以及一些需要前往二級頁面獲取資訊後回撥等元素。

表單的麻煩在於行與行之間資料往往沒有特定的規律,上圖中第二組資料中,姓名、性別、出生日期以及年齡,4個不同的 cell 則是 4個完全不同的互動方式來錄入資料,依照傳統的 UITableView 的代理模式來處理,有幾個弊端:

  • 在實現資料來源方法 tableView:cellForRowAtIndexPath:難免要對每一個 indexPath 進行 switch-case 處理,
  • 糟糕的是對於每一行的點選事件,```tableView:didSelectRowAtIndexPath:````方法,也要進行 switch-case 判斷
  • 因為 cell 的重用關係,每一行資料的取值也將嚴重依賴具體的 indexPath,資料的獲取變得困難,同樣地,編輯變化後的資訊也需要存到到資料模型中,對於跳轉二級頁面回撥的資料需要更新資料後要反過來重新整理對應的cell
  • 根據不同的入口,有一些 row 可能不存在或者需要臨時插入 cell,這使得寫死 indexPath 的 switch-case 很不可靠
  • 即便是靜態頁面的 cell,寫死了 indexPath 進行 switch-case 在未來的需求調整時(比如調整了 row 的位置,新增/減少了某些 row),變得難以維護。

3.解決方案

  • 回顧上面的弊端,很大的一個弊病在於嚴重的依賴了 row 的位置 indexPath 來獲取資料、繪製 cell、處理 cell 的事件以及回撥重新整理 row,藉助 MVVM 的思路,將每一行的檢視型別、檢視重新整理以及事件處理由每一行各自處理,用 GSRow 物件進行管理。
  • 單元格的構造,基於執行時和block,通過執行時構建cell,利用 row 物件的 cellClass/nibName 屬性分別從程式碼或者 xib 載入可重用的 cell 檢視備用
  • 呼叫 GSRow 的 configBlock 進行cell 內容的重新整理和配置(包括了 cell內部的block回撥事件)
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    GSRow *row = [self.form rowAtIndexPath:indexPath];
    
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.reuseIdentifier];
    if (!cell) {
        if (row.cellClass) {
            /// 執行時載入
            cell = [[row.cellClass alloc] initWithStyle:row.style reuseIdentifier:row.reuseIdentifier];
        } else {
          /// xib 載入
            cell = [[[NSBundle mainBundle] loadNibNamed:row.nibName owner:nil options:nil] lastObject];
        }
        /// 額外的檢視初始化
        !row.cellExtraInitBlock ?: row.cellExtraInitBlock(cell, row.value, indexPath);
    }
    
    NSAssert(!(row.rowConfigBlockWithCompletion && row.rowConfigBlock), @"row config block 二選一");
    
    GSRowConfigCompletion completion = nil;
    if (row.rowConfigBlock) {
        /// cell 的配置方式一:直接配置
        row.rowConfigBlock(cell, row.value, indexPath);
        
    } else if (row.rowConfigBlockWithCompletion) {
        /// cell 的配置方式二:直接配置並返回最終配置 block 在返回cell前呼叫(可用作許可權管理)
        completion = row.rowConfigBlockWithCompletion(cell, row.value, indexPath);
    }
    
    [self handleEnableForCell:cell gsRow:row atIndexPath:indexPath];
    
    /// 在返回 cell 前做最終配置(可做許可權控制)
    !completion ?: completion();
    
    return cell;
}
複製程式碼
  • 一個分組可以包含多個 GSRow 物件,在表單中對分組的頭尾部檢視並沒有高度定製和複雜的事件回撥,因此暫不做高度封裝,主要提供作為 Row 的容器以及整體隱藏使用,即GSSection。
@interface GSSection : NSObject

@property (nonatomic, strong, readonly) NSMutableArray <GSRow *> *rowArray;
@property (nonatomic, assign, readonly) NSUInteger count;
@property (nonatomic, assign) CGFloat headerHeight;
@property (nonatomic, assign) CGFloat footerHeight;
@property (nonatomic, assign, getter=isHidden) BOOL hidden;

`- (void)addRow:(GSRow *)row;
`- (void)addRowArray:(NSArray <GSRow *> *)rowArray;

@end
複製程式碼
  • 同理,多個 GSSetion 物件在一個容器內進行管理會更便利,設定 GSForm 作為整個表單的容器,從而資料結構為GSForm 包含多個 GSSection,而 GSSection 包含多個 GSRow,這樣與 UITableView 的資料來源和代理結構保持一致。
@interface GSForm : NSObject

@property (nonatomic, strong, readonly) NSMutableArray <GSSection *> *sectionArray;
@property (nonatomic, assign, readonly) NSUInteger count;

@property (nonatomic, assign) CGFloat rowHeight;

- (void)addSection:(GSSection *)section;
- (void)removeSection:(GSSection *)section;

- (void)reformRespRet:(id)resp;
- (id)fetchHttpParams;

- (NSDictionary *)validateRows;

/// 配置全域性禁用點選事件的block
@property (nonatomic, copy) id(^disableBlock)(GSForm *);

/// 根據 indexPath 返回 row
- (GSRow *)rowAtIndexPath:(NSIndexPath *)indexPath;
/// 根據 row 返回 indexPath
- (NSIndexPath *)indexPathOfGSRow:(GSRow *)row;

@end
複製程式碼

為了承載和實現 UITableView 的協議,將 UITabeView 作為控制器的子檢視,設為 GSFormVC,GSFormVC 同時是 UITableView 的資料來源dataSource 和代理 delegate,負責將 UITableView 的重要協議方法分發給 GSRow 和 GSSection,以及黑白名單控制,如此,具體的業務場景下,通過繼承 GSFormVC 配置 GSForm 的結構,即可實現主體功能,對於分組section的頭尾檢視等可以通過在具體業務子類中實現 UITableView 的方式來實現即可。

4.具體功能點的實現

4.1 支援完全自定義單元格 cell

當 UITableView 的 tableView:cellForRowAtIndexPath:方法呼叫時,第一步時通過 row 的 reuserIdentifer 獲取可重用的cell,當需要建立cell 時通過 GSRow 配置的 cellClass 屬性或者 nibName 屬性分別通過執行時或者 xib 建立新的cell 例項,從而隔離對 cell型別的直接依賴。 其中 GSRow 的構造方法

- (instancetype)initWithStyle:(UITableViewCellStyle)style
              reuseIdentifier:(NSString *)reuseIdentifier;

複製程式碼

接著配置 cell 的具體型別,cellClass 或者 nibName 屬性

@property (nonatomic, strong) Class cellClass;
@property (nonatomic, strong) NSString *nibName;

複製程式碼

為了在 cell 初始化後可以進行額外的子檢視構造或者樣式配置,設定 GSRow 的 cellExtraInitBlock,將在 首次構造 cell 時進行額外呼叫,屬性的宣告:

@property (nonatomic, copy) void(^cellExtraInitBlock)(id cell, id value, NSIndexPath *indexPath); 
// if(!cell) { extraInitBlock };

複製程式碼

下面是構造 cell 的處理

    GSRow *row = [self.form rowAtIndexPath:indexPath];
    
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.reuseIdentifier];
    if (!cell) {
        if (row.cellClass) {
            cell = [[row.cellClass alloc] initWithStyle:row.style reuseIdentifier:row.reuseIdentifier];
        } else {
            cell = [[[NSBundle mainBundle] loadNibNamed:row.nibName owner:nil options:nil] lastObject];
        }
        
        !row.cellExtraInitBlock ?: row.cellExtraInitBlock(cell, row.value, indexPath);
    }

複製程式碼

獲取到構造的可用的cell 後需要利用資料模型對 cell 的內容進行填入處理,這個操作通過配置rowConfigBlock 或者 rowConfigBlockWithCompletion 屬性完成,這兩個屬性只會呼叫其中一個,後者的區別時會在配置完成後返回一個 block 變數用於進行最終配置,屬性的宣告如下:

@property (nonatomic, copy) void(^rowConfigBlock)(id cell, id value, NSIndexPath *indexPath); 
// config at cellForRowAtIndexPath:
@property (nonatomic, copy) GSRowConfigCompletion(^rowConfigBlockWithCompletion)(id cell, id value, NSIndexPath *indexPath); 
// row config at cellForRow with extra final config
複製程式碼

4.2 支援自動佈局AutoLayout和固定行高

自 iOS8 後 UITableView 支援高度自適應,通過在 GSFormVC 內對 TableView 進行自動佈局的設定後,再在各個 Cell 實現各自的佈局方案,表單的佈局思路可以相容固定行高和自動佈局,TableView 的配置:

- (UITableView *)tableView {
    if (!_tableView) {
        _tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleGrouped];
        _tableView.delegate = self;
        _tableView.dataSource = self;
        _tableView.backgroundColor = [UIColor groupTableViewBackgroundColor];
        _tableView.tableFooterView = [[UIView alloc] init];
        _tableView.rowHeight = UITableViewAutomaticDimension;
        _tableView.estimatedRowHeight = 88.f;
    }
    
    return _tableView;
}
複製程式碼

對應地,GSRow 的 rowHeight 屬性可以實現 cell高度的固定,如果不傳值則預設為自動佈局,屬性的宣告:

@property (nonatomic, assign) CGFloat rowHeight;
複製程式碼

進而在 TableView 的代理中實現 cell 的高度佈局,如下:

- (CGFloat)tableView:(UITableView *)tableView
        heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    GSRow *row = [self.form rowAtIndexPath:indexPath];
    
    return row.rowHeight == 0 ? UITableViewAutomaticDimension : row.rowHeight;
}
複製程式碼

4.3 表單每行row資料和事件整合為一個model,基本只需管理row

為了方便行資料的儲存,設定了專門用於存值的屬性,根據實際的需要進行賦值和取值即可,宣告如下:

@property (nonatomic, strong) id value;
複製程式碼

在實際的應用中,value 使用可變字典的場景居多,如果內部有特定的自定義類物件,可以用一個key值儲存在可變字典value中,方便存取,value 作為可變字典使用時有極大的自由便利性,可以在其中儲存有規律的資訊,比如表單cell 左側的 title,右側的內容等等,因為 block 可以時分便利地捕獲上下物件,而且 GSForm 的設計實現時一個 GSRow 的幾乎所有資訊都在一個程式碼塊內實現,從而實現上下文的共享,在上一個block存值時的key,可以在下一個block方便地得知用於取值和設值,比如一個 GSRow 的配置:

- (GSRow *)rowForTrace {
      GSRow *row = nil;
    
    GSTTraceListRespRet *model = [[GSTTraceListRespRet alloc] init];
    row = [[GSRow alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"GSLabelFieldCell"];
    row.cellClass = [GSLabelFieldCell class];
    row.rowHeight = 44;
    row.value = @{kCellLeftTitle:@"跟蹤方案"}.mutableCopy;
    row.value[kCellModelKey] = model;
    row.rowConfigBlock = ^(GSLabelFieldCell *cell, id value, NSIndexPath *indexPath) {
        cell.leftlabel.text = value[kCellLeftTitle];
        cell.rightField.text = model.name;
        cell.rightField.enabled = NO;
        cell.rightField.placeholder = @"請選擇運輸跟蹤方案";
        cell.accessoryView = form_makeArrow();
    };    
    
    WEAK_SELF
    row.reformRespRetBlock = ^(GSTGoodsOriginInfoRespRet *ret, id value) {
        model.trace_id = ret.trace_id;
        model.name = ret.trace_name;
    };
    
    row.didSelectBlock = ^(NSIndexPath *indexPath, id value) {
        STRONG_SELF
        GSTChooseTraceVC *ctl = [[GSTChooseTraceVC alloc] init];
        ctl.chooseBlock = ^(GSTTraceListRespRet *trace){
            model.trace_id = trace.trace_id;
            model.name = trace.name;
            [strongSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
        };
        [strongSelf.navigationController pushViewController:ctl animated:YES];
    };
  return row;
}
複製程式碼

對於需要在點選 row 時跳轉二級頁面的情況,通過配置 GSRow 的 didSelectBlock 來實現,宣告及示例如下:

@property (nonatomic, copy) void(^didSelectCellBlock)(NSIndexPath *indexPath, id value, id cell); 
// didSelectRow with Cell

    row.didSelectBlock = ^(NSIndexPath *indexPath, id value) {
        STRONG_SELF
        GSTChooseTraceVC *ctl = [[GSTChooseTraceVC alloc] init];
        ctl.chooseBlock = ^(GSTTraceListRespRet *trace){
            model.trace_id = trace.trace_id;
            model.name = trace.name;
            [strongSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
        };
        [strongSelf.navigationController pushViewController:ctl animated:YES];
    };
複製程式碼

通過對該屬性的配置,在 TableView 的代理方法 tableView:didSelectRowAtIndexPath: 來呼叫:

- (void)tableView:(UITableView *)tableView
         didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [tableView deselectRowAtIndexPath:indexPath animated:YES];
    
    GSRow *row = [self.form rowAtIndexPath:indexPath];
    !row.didSelectBlock ?: row.didSelectBlock(indexPath, row.value);
    UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
    !row.didSelectCellBlock ?: row.didSelectCellBlock(indexPath, row.value, cell);
}

複製程式碼

綜上,通過多個屬性的配合使用,基本達成了 cell 的構造、配置和 cell內部事件以及 cell 整體點選事件的整合。

4.4 積木式組合 row,支援 section 和 row 的隱藏,易於維護

基於每行資料及其事件整合在 GSRow 內,具備了獨立性,通過根據需求整合到不同的 GSSection 後即可搭建成具體的業務頁面,舉例:

/// 構造頁面的表單資料
- (void)buildDataSource {
    [self.form addSection:[self sectionChooseProject]];
    [self.form addSection:[self sectionTransportSettings]];
    [self.form addSection:[self sectionUploadAddress]];
    [self.form addSection:[self sectionDownloadAdress]];
    [self.form addSection:[self sectionOtherInfo]];
}
複製程式碼

此外,GSSection/GSRow 都支援隱藏,根據不同的場景設定 GSSection/GSRow 的隱藏狀態,可以動態設定表單。

@property (nonatomic, assign, getter=isHidden) BOOL hidden;
複製程式碼

隱藏屬性將通過 UITableView 的資料來源 dataSource 協議方法決定是否顯示 section/row:

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    NSInteger count = 0;
    for (GSSection *section in self.form.sectionArray) {
        if(!section.isHidden) count++;
    }
    
    return count;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    GSSection *fSection = self.form[section];
    NSInteger count = 0;
    for (GSRow *row in fSection.rowArray) {
        if(!row.isHidden) count++;
    }
    
    return count;
}

複製程式碼

也正是因為GSSection/GSRow 的隱藏特點,根據 indexPath 取值時不能單方面地根據索引從陣列中取值,也應考慮到是否有隱藏的物件,為此在 GSForm 定義了兩個工具方法,用於關聯 indexPath 與 GSRow 物件,在必要時呼叫。

/// 根據 indexPath 返回 row
- (GSRow *)rowAtIndexPath:(NSIndexPath *)indexPath;
/// 根據 row 返回 indexPath
- (NSIndexPath *)indexPathOfGSRow:(GSRow *)row;
複製程式碼

通過這些可組合性,可以便利地搭建頁面,且易於增刪或者調整順序。

4.5 支援傳入外部資料

有些編輯型別的表單,首次載入時通過其他渠道載入資料後先填入一部分值,為此,GSRow 設計了從外部取值的屬性 reformRespRetBlock,而外部引數經由 GSForm 進行遍歷呼叫。

///GSForm
/// 傳入外部資料
- (void)reformRespRet:(id)resp;
- (void)reformRespRet:(id)resp {
    for (GSSection *section in self.sectionArray) {
        for (GSRow *row in section.rowArray) {
            !row.reformRespRetBlock ?: row.reformRespRetBlock(resp, row.value);
        }
    }
}
/// GSRow 從外部取值的block配置
@property (nonatomic, copy) void(^reformRespRetBlock)(id ret, id value);    
 // 外部傳值處理
複製程式碼

如此,通過網路請求的資料返回後呼叫 GSForm 將資料分發到 GSRow 存入到各自的 value 後,重新整理 TableView 即可實現外部資料的匯入,比如網路請求後呼叫構建頁面各個 GSRow 並 傳入外部資料:


SomeHTTPModel *result; // 網路請求成功返回值
self.result = result;
[self buildForm];
[self.form reformRespRet:result];
[self.tableView reloadData];
複製程式碼

4.6 支援快速提取資料

對應地,當資料錄入完成後,點選提交時,需要獲取各行資料進行網路請求,此時根據業務場景各自通過,通過每個 GSRow 配置各自的請求引數即可,宣告配置請求引數的屬性 httpParamConfigBlock,以從表單中提取一個字典引數為例: 宣告:

@property (nonatomic, copy) id(^httpParamConfigBlock)(id value); 
// get param for http request
複製程式碼

從表單中獲取請求引數:

/// 獲取當前請求引數
- (NSMutableDictionary *)fetchCurrentRequestInfo {
    NSMutableDictionary *dic = [NSMutableDictionary dictionary];
    for (GSSection *secion in self.form.sectionArray) {
        if (secion.isHidden) continue;
        
        for (GSRow *row in secion.rowArray) {
            if (row.isHidden || !row.httpParamConfigBlock) continue;
            
            id http = row.httpParamConfigBlock(row.value);
            if ([http isKindOfClass:[NSDictionary class]]) {
                [dic addEntriesFromDictionary:http];
            } else if ([http isKindOfClass:[NSArray class]]) {
                for (NSDictionary *subHttp in http) {
                    [dic addEntriesFromDictionary:subHttp];
                }
            }
        }
    }
    return dic;
}

複製程式碼

4.7 支援引數的最終合法性校驗

一般地,對使用者輸入的引數在提交前需要進行合法性校驗,對於較長的表單而言通常是點選提交按鈕時進行,對引數的最終合法性進行逐個校驗,當引數不合法時進行提醒,將合法性校驗的要求宣告為 GSRow 的屬性進行處理,如下:

/// check isValid
@property (nonatomic, copy) NSDictionary *(^valueValidateBlock)(id value);
複製程式碼

返回值為字典,其中字典的內容並不嚴格限制,一個好的實踐是:用一個key 標記校驗是否通過,另外一個key標記校驗失敗的提醒,比如:

    row.valueValidateBlock = ^id(id value) {
        // 校驗失敗,返回一個 key 為 @NO 的字典,並攜帶錯誤地址。
        if(![value[kCellModelKey] count]) return rowError(@"XX時間不可為空");
    
        return rowOK(); // 返回一個 key 為 @YES 的字典
    };
複製程式碼

如此,可由整個表單 GSForm發起整體校驗,做遍歷處理,舉例如下:

/// GSForm
- (NSDictionary *)validateRows;
- (NSDictionary *)validateRows {
    for (GSSection *section in self.sectionArray) {
        for (GSRow *row in section.rowArray) {
            if (!row.isHidden && row.valueValidateBlock) {
                NSDictionary *dic = row.valueValidateBlock(row.value);
                NSNumber *ret = dic[kValidateRetKey];
                NSAssert(ret, @"必須有結果引數");
                if (!ret) continue;
                if (!ret.boolValue) return dic;
            }
        }
    }
    
    return rowOK();
}

// 業務方的使用
/// 檢查引數合法性,如不合法冒泡提醒
- (BOOL)validateParameters {
    NSDictionary *validate = [self.form validateRows];
    if (![validate[kValidateRetKey] boolValue]) {
        NSString *msg = validate[kValidateMsgKey]; // 錯誤提示資訊
        [GSProgressHUD showWithTitle:msg inView:self.view];
        return NO;
    }
    return YES;
}

複製程式碼

4.8 支援資料模型的型別完全自由自定義,可拆可合

某一行的業務資料可以獨立存在 GSRow 的value中,也可以直接使用 控制器外部的屬性/例項變數,根據實際的情況便利性決定; 同理,在配置請求引數時,也可以根據網路層設計的需要決定,如果是配置一個自定義Model,則事先在外部宣告懶載入一個請求引數,在 httpConfigBlock 中對應屬性進行設值,如果是配置一個 字典,則可以獨立提供一個 字典又或者乾脆對外部的一個可變字典設值。

4.9 支援設定row的白名單和黑名單及許可權管理

在特定的場景下,只能編輯個別cell,這些可以編輯的cell應加入白名單;在另外一個特定的場景下,不能編輯個別cell,這些不能編輯的cell應加入黑名單,在白黑名單之上,可能還夾雜一些特定許可權的控制,使得只有特定許可權時才可以編輯。針對這類需求,通過在 cell 檢視上層覆蓋一個可操作性攔截按鈕進行處理,通過配置 GSRow 的 enableValidateBlock 和 disableValidateBlock 屬性進行實現。

/// GSForm
/// 傳入此值實現全域性禁用,此時點選事件的 block 
@property (nonatomic, copy) id(^disableBlock)(GSForm *);


/// GSRow 的黑名單
@property (nonatomic, copy) NSDictionary *(^disableValidateBlock)(id value, BOOL didClick);
/// GSRow的白名單
@property (nonatomic, copy) NSDictionary *(^enableValidateBlock)(id value, BOOL didClick);
複製程式碼

延伸

經過在專案中的應用,這個框架基本成型,並具備相當高的定製能力和靈活性,在後續的功能開發上會進一步迭代。 以下是幾個注意點:

  • 在一些 cell 不規則/規則的靜態頁面,也適合使用。
  • 此框架處處都是 block 的應用,因此應格外注意避免迴圈引用的發生,因為 控制器持有 GSForm 和 UITableView,所以在 GSRow 的 block 屬性配置,以及內部 GSRow配置 cell 的 cellConfigBlock 內又有 cell.textChangeBlock 這類情況,需要進行雙重的弱引用處理,比如:
    WEAK_SELF
    row.rowConfigBlock = ^(GSTCodeScanCell *cell, id value, NSIndexPath *indexPath) {
        STRONG_SELF
        cell.textChangeBlock = ^(NSString *text){
            value[kCellRightContent] = text;
        };
        
        /// 因為 cell 的block 是 強引用,所以這類需要再次設定弱引用。
        __weak typeof(strongSelf) weakWeakSelf = strongSelf; 
        cell.scanClickBlock = ^(){
            GSQRCodeController *scanVC = [[GSQRCodeController alloc] init];
            scanVC.returnScanBarCodeValue = ^(NSString *str) {
                value[kCellRightContent] = str;
                [weakWeakSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
            };
            [weakWeakSelf.navigationController pushViewController:scanVC animated:YES];
        };
        
        cell.selectionStyle = UITableViewCellSelectionStyleNone;
    };
複製程式碼
  • 此外也有許多其他方案可供學習:
  1. 最常提及的 XLForm@Github

分享

GitHub 和 Demo 下載

相關文章