如何在 iOS Apps 創建展開式 UITableView

幾乎所有的 App 都會以導航的方式向用戶展示多個 View Controller。這些 View Controller 分別擔任不同的職責,比如在屏幕上簡單顯示一些信息,或者從用戶輸入中收集複雜數據。針對 App 的不同功能設計新的 View Controller 是一件必不可少的任務,有時候甚至是一種頗為艱鉅的任務。但是,許多時候,通過展開式 UITableView,我們卻可以不用創建新的 View Controller 就能完成同樣的任務。


顧名思義,一個展開式 UITableView 是這樣一種表視圖,它「允許」其單元格(cell)展開或者收起,顯示或者隱藏,而在一般的表視圖中,它們的單元格只能是顯示的狀態。當我們需要收集一些簡單的數據或者根據用戶的意願顯示/隱藏某些內容時,創建展開式 UITableView 是一種不錯的選擇。這樣,我們就沒有必要僅僅為了讓用戶輸入一些數據就創建新的 View Controller,無論如何我們都只需要呆在同一個 View Controller 裡面,即當前的 View Controller 中。例如,通過展開式的 cell,我們顯示或隱藏一個用於給用戶輸入信息的表單,在顯示或隱藏這個表單時,根本不需要離開當前的 View Controller。

是否採用或者不採用展開式的 UITableView,完全取決於 App 的性質。但是,只要是通過子類化UITableViewCell和自定義xib文件的方式來定製 cell 的情況,App 的外觀就不會是什麼問題。因此,歸根結底,這只是一個需求問題。

在本教學中,我將演示一種創建展開式 UITableView 的簡單有效的方法。注意,這並不是唯一方法。最好的方法要視 App 的需要而定,我的目的只是展示一種一般化的解決方案,它在大部份情形下都是適用的。因此,請進入下一部份,看看本教程最終將實現什麼樣的效果。

示例 App

我們將創建一個只有一個 View Controller (其包含有一個 TableView)的 App,在這個 App 中我們將演示如何創建一個展開式表視圖。我們將模擬一個允許用戶輸入的表單,為了演示,這個 TableView 將由 3 個 section 構成:

  1. Personal
  2. Preferences
  3. Work Experience

每個 section 都會包含展開式 cell,這些 cell 會隱藏/顯示該 section 的其它 cell。尤其是位於 section 頂部的 cell (該 cell 能夠展開或收起):

對於 「Personal」 section:

  1. Full name: 這個 cell 用於顯示用戶的全名,當它處於展開狀態時,它下面會多出兩個子 cell,分別用於輸入名和姓。
  2. Date of birth: 用於顯示用戶的生日。當它被展開后,會顯示一個日期選擇器(UIDatePicker),允許用戶選擇某個日期並提供一個按鈕將用戶選擇的日期返回給它上面的 cell。
  3. Marital status: 顯示用戶的婚姻狀態:已婚或單身。當它被展開后,會顯示一個開關控件,允許用戶設置他們的婚姻狀態。

對於「Preferences」section:

  1. Favorite sport: 我們模擬了一個運動種類列表,用於提供給用戶,讓他們從中選擇他們所喜愛的運動。當它被展開時,會列出 4 個運動種類,當用戶選擇某個子項,這個 cell 又自動會被收起。
  2. Favorite color: 和上面非常相似,只不過這裡顯示了一個顏色列表供用戶選擇。

對於「Work Experience」section:

Level: 當這個 cell 被點擊并展開后,將顯示另一個包含有一個滑動條的 cell,允許用戶設置他們的工作經驗等級。這個級別用一個 0…10 之間的數字表示,我們只取這個數值的整數部份。

通過下面的動畫會看得更清楚一些:

t45_7_expand_collapse

注意上面的例子,當我們展開 TableView 時會顯示不同類型的 cell。這些 cell 都被包含在開始項目中了,你可以下載這些代碼,項目已經完成了一些前期的準備工作。所有的 cell 都在單獨的xib文件進行了必要的設計,同時它們的 Custom Class 也被指定為自定義的 UITableViewCell 子類(即 CustomCell):

t45_2_custom_class

在項目文件夾中,你將發現這些 cell 所使用的 xib 文件,包括:

t45_3_cell_list

它們作用分別如其名稱所示,你也可以下載開始項目深入探究一番。

除了 cell,你還會發現一些已經寫好的代碼。雖然這些代碼對於實現整個示例 App 的功能來說是必不可少的,但卻不屬於本教程的核心內容,因此我會跳過這些代碼,僅僅是以現成的代碼提供在開始項目中。缺失的其餘代碼是本教程中我們最關心的內容,在接下來的教程中會以 step-by-step 的方式添加到項目中。

到此,你已經知道我們最後的目標是什麼了,接下來就讓我們開始學習如何創建展開式的 UITableView。

描述單元格

我將在本教程中演示的、所有與展開式 UITableView 相關的實現和技術,都基於這樣一種簡單的思路:向 App 描述每個 cell 的細節。通過這種方式我們讓 App 知道每一個 cell 到底是展開的還是收起的,是可見的還是隱藏的,每個 cell 的文字標籤顯示什麼內容,等等。實際上,整體思路都基於將屬性集進行編組,這些屬性要麼描述了每個 cell 的屬性,要麼包含了 cell 的某些數值,然後將這些屬性告訴給 App,這樣 App 才能正確地顯示它們。

在本示例程序中,我創建和使用了一個屬性集合,如下面所列。注意在真正的 App 中,你可能需要增加新的屬性,或者對某些屬性進行修改。不過,此時你只需要了解大致的情況就可以了。當然,只要你願意你可以任意修改這些屬性。我們所使用的屬性列表(plist)是這樣的:

  • isExpandable: 一個布爾值,標明 cell 是否能夠展開或收起。在本教程中,這是我們非常關心的重要屬性。
  • isExpanded: 一個布爾值,標明一個展開式 cell 是處於展開狀態還是收起狀態。頂層的 cell 默認是收起狀態,因此這個值一開始都應該設成 NO。
  • isVisible: 顧名思義,標明這個 cell 是否應該顯示到表格中。稍後這個屬性會扮演一個重要的角色,因為我們會根據這個屬性讓表格中的某些 cell 得到顯示。
  • value: 這個屬性用於保存 UI 控件的值(例如,開關控件中的婚姻狀態)。不是所有 cell 都會有這樣的控件,因此大部份 cell 的這個屬性值將保留為空。
  • primaryTitle: cell 主標題的顯示文本,當這個屬性不為空時,這個屬性的值會顯示到 cell 上。
  • secondaryTitle: cell 子標題的顯示文本,或者 cell 第二個標籤的顯示文本。
  • cellIdentifier: 自定義 cell 的ID,用於唯一識別當前 cell 的描述。這個 ID 不仅被 App 用於从缓存队列中弹出合适的 cell,而且还要根據这个 ID 对要顯示的 cell 進行相應的處理并指定 cell 的高度。
  • additionalRows: 用於表示當一個展開式單元格被展開時,它下面包含了幾個附屬的 cell。

每個 cell 都會用上面的屬性集進行描述。從 App 的角度,我們使用一個屬性列表(plist)文件來保存它們會更加輕鬆。在這個plist文件中,我們會為每個 cell 使用一個上述屬性集來進行描述,并適當地填充屬性集中的屬性值,這樣,我們將最終獲得所有 cell 的一個完整的描述,這個描述對於我們或 App 來說都很容易理解。同時我們并沒有為之編寫一行代碼。很不错吧?

現在,我們在項目中新建一個 plist 文件,然後用適當的數據來填充它。當然你也可以從這裡下載現成的.plist 文件。下載后記得將它添加到我們的開始項目中。手動設置所有 cell 的屬性會佔用大量空間,這是完全沒有必要的,同時拷貝-粘貼或者輸入所有屬性值也是一件很繁瑣的事情。
然後,讓我們來討論一下這個 plist 文件:

首先,你下載的這個文件的文件名叫做CellDescriptor.plist。它的根節點(root)是一個數組,其中的每個元素表示表格中的一個 section。也就是說這個plist文件的 root 數組中有三個元素,就跟我們想在表格中顯示的 section 的數目一樣。

每個 section 本身也是一個數組,數組中包含了該 section 中所包含的所有 cell 的描述。實際上,這些編組的屬性集在這裡用字典來進行表示,每個字典代表了一個單獨的 cell。這是一個 plist 文件的例子:

t45_4_plist_sample

現在,是時候來完整地回顧一下我們將要顯示到表格中的 cell 的屬性集和屬性值了。很顯然,擁有了這些 cell 描述之後,我們需要編寫用於生成、管理 cell 的代碼大大減少了。我們也不需要告訴 App 這些 cell 的各種狀態(例如,哪個單元格是可展開的,App 應當讓某個 cell 展開或收起,判斷某個 cell 是可見的還是隱藏的等等)。所有的這些信息都包含在你下載的 plist 文件裡面。

加載單元格描述

終於可以編寫代碼了,雖然我們使用的單元格描述技術為我們節省了許多時間,但在這個項目中我們仍然免不了要編寫代碼。現在,我們已經有了用於描述 cell的 plist 文件了,接下來的事情自然是用代碼將文件內容加載到一個數組對象中。這個數組對象將在後面充當表格的數據源。

打開開始項目中的 ViewController.swift 文件,在類的頂部聲明如下屬性:

這個數組將用於包含所有來自于 plist 文件中的用於描述每個 cell的字典。

然後,新增一個方法,用於將文件內容加載到數組對象。我們將這個方法命名為 loadCellDescriptors()

這個方法非常簡單:首先我們我們判斷指定的 plist 文件路徑在 bundle 中是否存在,如果存在我們從文件中加載一個數組并初始化 cellDescriptors 變量。

接下來就是調用這個方法,我們將在 TableView 已經配置好,並且視圖即將顯示之前(即在 TableView 已經創建並且還沒有顯示任何內容之前)調用這個方法:

如果在上述方法最後一行後添加 print(cellDescriptors) 一句,則運行 App 后你將看見 plist 文件的內容輸出到了控制台中。這就說明文件已經成功加載到內存了。

t45_5_console_plist

通常,本節的內容應該到此結束,但這次有一點例外。我們還要補充一些對於下一節來說至關重要的內容。也許你想到了(尤其是當你檢查了CellDescriptor.plist文件之後),當 App 啟動后,并不是所有的 cell 都應該被顯示。事實上,我們根本無法得知它們是否會在同時顯示,因為它們是根據用戶的要求來進行展開和收起的。

從編程的角度,這意味著 每個 cell 的行索引不應該是常量 (這就是為什麼我們在處理每個 cell 時,將 indexPath.row 用代碼來生成)。同時,我們也不能用 cell 的行索引來遍歷數據源數組并顯示每個 cell。我們只能將可見的 cell 的行索引來提供給 App。如果將 cell 描述中標記為不可見的 cell 顯示出來,這就大錯特錯了,那會導致 App 表現異常。

基於這樣的原因,我們需要實現一個新方法,叫做 getIndicesOfVisibleRows()。這個方法的作用是顯而易見的:它只返回那些標記為可見的 cell 的行索引。在實現這個方法之前,請在類的頂部增加如下屬性:

這是一個二維數組,保存了所有 section 的可見的 cell 的行索引(一維用於表示 section,一維用於表示 cell)。

現在來實現這個方法。你也許想到了,我們會遍歷所有 cell 描述并將 isVisible 屬性為 true 的 cell 的行索引添加到二維數組中。當然,我們不得不用到嵌套循環,但這也不是什麼大問題。下面是這個方法的實現:

注意,方法的一開始就將 visibleRowsPerSection 數組的內容清空了,否則連續多次調用這個方法之後數據就不正常了。接下來的實現就一目了然了,就不用我再多說了。

這個函數的第一次調用應該在從 plist 文件加載完 cell 描述之後(我們還會在後面多次調用這個函數)。因此,回到本節實現的第一個方法,將它修改為:

雖然 TableView 還不能正常工作,但我們已經在 App 一啟動的時候就調用了它的刷新動作,這樣就能保證在接下來的的步驟中顯示正確的 cell。

顯示單元格

每當 App 一啟動,cell 描述就會被加載,接下來我們應當處理和顯示表格中的 cell。一開始,我們需要創建一個新的方法,用於在 cellDescriptors 數組中查找并返回指定 cell 的單元格描述。正如下面的代碼所示,這個方法能夠正常工作的前提,是你已經擁有一個填充好數據的 visibleRowsPerSection 數組。

這個方法的參數是某個 cell 的 IndexPath 值(NSIndexPath),這個 cell 就是 TableView 當前正在處理的那個 cell。這個方法返回了一個字典對象,包含了該 cell 的全部屬性值。在方法體中,首先需要根據給定的 IndexPath 去可見行數組中進行匹配,這個任務非常簡單,我們只需要提供這個 cell 的 section 索引和行索引就可以了。現在你可能還有點摸不著頭腦,因為我們還沒有介紹 TableView 的委託方法,因此我必須先告訴你每個 section 的行數應當等於每 section 中可見 cell 的個數。也就是說,在上面的代碼中,我們必須保證每個 indexPath.row 都能在 visibleRowsPerSection 中找到對應的可見的 cell 的索引。

擁有了每個 cell 的行索引之後,我們就來處理和「讀取」從 cellDescriptors 數組獲取的 cell 描述字典。注意,我們在指定這個數組的第二個下標索引時,使用的是 indexOfVisibleRow 而不是 indexPath.row。如果你使用了後者,得到的數據是不正確的。

實現了這個工具方法之後,我們後面就比較輕鬆了。接下來我們開始修改 ViewController 類中的 TableView 方法。首先,指定 TableView 的 section 數:

在這個方法中,我們必須考慮到 cellDescriptor 數組為 nil 的情況。只有當它不為空且填充了 cell 描述時我們才返回它的長度。

然後,讓我們指定每 section 的行數。就像我剛才所說的,這個數字應該等於可見 cell 的數目,我們只需要一行代碼就可以搞定這個:

接下來,是每一個 section 的標題:

然後,指定每行的行高:

這裡需要說明一下:我們第一次使用了 getCellDescriptorForIndexPath: 方法,這個方法是我們在前面實現了的。我們需要獲得每個 cell 的描述,因為我們接著還需要讀取 cellIdentifier 屬性,用這個值去決定行的高度。關於每個 cell 的高度,我們可以打開相應的 cell 的 xib 文件獲知(或者你也可以不用管,直接使用這裡提供的數值好了)。

最後,才是真正去顯示 cell。首先,從單元格重用隊列中出列一個 cell:

再一次,我們根據當前 IndexPath 來獲取正確的單元格描述,并通過 cellIdentifier 屬性從單元格重用隊列中出列一個 cell,然後分別針對每種 cell 進行單獨的處理:

對於一般的 cell,我們只是將 primaryTitlesecondaryTitle 的文本值賦給 textLabeldetailTextLabel 標籤。在本示例程序中,ID 為 idCellNormal 的 cell 實際上是位於 section 頂層的 cell,正是這個 cell 能夠進行展開和收起的動作。

對於帶有一個 TextField 的 cell,我們只是用單元格描述的 primaryTitle 屬性去設置它的 placeholder 值。

對於帶有一個開關控件的 cell,我們需要做兩個動作:首先設置開關控件的顯示文本(在 CellDescriptor.plist 文件中這是一個常量,當然你可以修改它),然後根據描述中的 value 屬性是否為 true 來設置開關的 on 屬性。注意,之後我們還會改變這個值。

還有一種 ID 為 idCellValuePicker 的 cell。這種 cell 表示它會提供一個選擇列表,當我們選中列表中的某個選項,父 cell 將會自動收起,同時父 cell 的 textLabel 將做相應改變。

最後,是帶有一個滑動條的 cell。我們僅僅是從 currentCellDescriptor 字典中取出當前的 value 值轉換為一個 Float 數字,然後賦給滑動條,讓它總是(在可見的時候)顯示正確的值。稍後我們也會改變這個值以及與之對應的單元格描述。

對於 ID 不在上述 if 語句檢查條件中的 cell,本示例 App 不會進行任何處理。當然,如果你不想採取這種方式,只需要修改上述代碼并添加缺少的語句即可。

現在你可以先運行一下程序,看看運行的結果。當然不會看到更多的 cell,因為你只能看到頂層的 cell。別忘記我們還沒有實現展開/收起功能,因此你點擊 cell 也不會發生什麼。但你不用沮喪,因為你看到的這個結果已經表明我們剛才所做的一切已經生效了。

t45_6_top_level_cells

展開/收起

這部份內容可能是你最感興趣的內容了,因為本教程的目標即將在這裡達成。首先我們將讓我們的頂層 cell 在被點擊之後展開/收起,同時子 cell 會適時地顯示/隱藏。

首先需要知道被點到的 cell 位於哪一行(注意,并不是 indexPath.row,而是可見單元格的行索引),因此,我們需要在下面的 TableView 委託方法中將行索引保存到某個局部變量:

儘管讓我們的 cell 展開/收起用不著多少代碼,我仍然打算以 step-by-step 的方式進行講解。一則這會使我的思路更加清晰,二則也方便你了解每個動作的真正含義。現在,我們擁有了被點擊的 cell 的真正的行索引,我們可以用它來檢索 cellDescriptors 數組,看那個 cell 是否是一個“可展開的”的 cell。如果它是“可展開”的,同時還沒有展開,則我們將認為它應該被展開(用一個標誌變量來表示),否則我們認為它應該被收起:

當我們通過一系列條件計算出 cell 是否該被展開或收起之後,我們需要將這個值存到單元格描述集合里,也就是說,我們要修改 cellDescriptors 數組。我們要修改的是選中的 cell 的 isExpanded 屬性,這樣它才會在再次被點擊時表現正確(cell 的 isExpandedtrue ,則再次點擊時它會收起,否則再次點擊后它會展開)。

這裡我們不應該忘記一個重要的細節:回想一下,在單元格描述中,有一個表明 cell 是否應當顯示的屬性 isVisible。這個屬性也應當做相應的改變,這樣那些新增的行才會在 cell 被展開時從隱藏變為顯示,或者在 cell 被收起時由顯示變成隱藏。事實上,只有改變這個值才能真正實現展開(或相反)的效果。因此,我們需要修改上述代碼,在頂層 cell 被點擊后修改其附屬 cell 的 isVisible 屬性。

我們已經離我們的目標不遠了,但我們還需要注意一件很重要的事情:在上述代碼中,我們剛剛修改了某些 cell 的 isVisible 屬性,這導致整個可視 cell 的行數也改變了。因此,在我們刷新表格之前,我們還要讓 App 重新計算可視 cell 的行索引:

你也看到了,我以動畫的方式重新加載了被點擊的 cell 的 section。當然,如果你不喜歡這種方式的話,你可以修改它。

運行 App 進行測試。連續點擊頂層 cell,cell 隨之展開和收起,雖然現在與子 cell 進行交互還不會發生任何事情,但這個結果看起來非常不錯!

t45_7_expand_collapse

獲取輸入內容

從這裡開始,我們要將精力放在數據處理以及用戶和子 cell 控件進行的交互上。對於 ID 為 idCellValuePicker 的 cell,我們將代碼邏輯實現在當 ID 為 idCellValuePicker 的 cell 被點擊的時候。對於本示例程序,在表格的 Preferences section中,有一些 cell 會羅列用戶喜愛的運動和顏色。雖然我已經說過,但這裡我仍然要再說一次,就當是加強一下我們的記憶:當這類 cell 被點擊時,我們想讓對應的頂層 cell 收起(或者隱藏),所選中的值會顯示到頂層 cell。

我之所以一開始就來處理這類 cell,是因為這是我們最後一次還需要和 TableView 的委託方法打交道。在這裡,我們會添加一個 else 分支來處理“不可展開的”cell,然後再對被點到的 cell 的 ID 值進行判斷。如果 ID 值等於 idCellValuePicker,則進行相應的處理。

在內層的 if 語句中,我們將分四個單獨的步驟進行處理:

  1. 找出頂層 cell 的行索引,也就是被點擊的 cell 的「父 cell」的行索引。實際上,我們只需要從這個 cell 的單元格描述向前搜索,所找到的第一個頂層 cell 就是我們要找的 cell(即第一個可展開的 cell)。
  2. 將選中的 cell 的顯示文本賦給頂層 cell 的 textLabel 的 text 屬性。
  3. 將頂層 cell 的 expanded 標記為 false
  4. 將頂層 cell 的所有的子 cell 標記為隱藏。

實現為代碼則是:

當我們修改了某些 cell 的 isVisible 屬性之後,可見 cell 的數目就被改變。因此最後兩句是必須的。

現在運行程序,當你選擇了一個喜愛的運動或顏色后 App 會進行適當的響應:

t45_8_select_preferences

響應其它動作

CustomCell.swift 文件中,找到 CustomCellDelegate 協議,這裡我們已經對所有 required 方法進行了定義。在 ViewController 類中實現這些方法,我們將使 App 能夠對其它動作進行響應。

打開 ViewController.swift 文件,聲明遵循於該協議。在類聲明的頂部加入該協議:

接著,在 tableView:cellForRowAtIndexPath: 方法中,將每個 CustomCell 的委託指定為 ViewController 類。在方法體中,在方法即將返回之前,加入這句代碼:

好了,現在我們可以來實現委託方法了。第一個方法是當用戶在 DatePicker 中選定一個日期后,我們將所選定的日期顯示在與之對應的頂層 cell:

在指定好適當的 section 索引和行索引后,我們將選定日期以字符串格式賦給對應 cell 的單元格描述。注意這個字符串是委託方法通過參數來傳遞給我們的。

然後是帶有開關控件的 cell。當開關控件的值改變時,我們需要做兩件事情:首先,將合適的值( Single 或 Married)傳遞給對應的頂層 cell,同時用開關控件的值更新 cellDescriptors 數組,這樣當表格刷新后開關控件就會顯示正確的狀態。在下面的代碼中,注意我們首先基於開關控件的狀態來決定適當的值,然後將它們賦給對應的屬性:

接下來是帶有 TextField 的 cell。這裡,當用戶的姓或者名輸入有內容時,我們會動態組裝用戶的全名。因此,我們需要指明包含有 TextField 的 cell 的行索引,并根據索引的不同將字符串添加到全名中去(名在前,姓在后)。最後,我們需要更新頂層 cell 的文字,刷新表格,以使它反映出用戶輸入內容的改變:

最後,是帶有滑動條的那個 cell,即「Work Experience」 section 需要我們處理。當用戶拖動滑塊,我們需要同時完成兩件事:用新的滑動條的數值修改頂層 cell 的文本內容(即「經驗級別」),以及將滑動條的值保存到對應的 cell 描述中,使其在刷新表格后能夠更新界面。

最後一塊拼圖已經完成,接下來就是運行 App 進行測試。

總結

正如我一開始所說,有時創建一個展開式 TableView 真的很有用,因為它讓你直接在表格中處理以前必須創建新 View Controller 才能解決的問題。在教程的前半部份,我演示了一種創建展開式 TableView 的方法,它的主要特點是在一個屬性列表文件(plist)中以屬性集的方式來描述每個 cell。我還演示了在單元格顯示、展開和選定時,如何用代碼來處理單元格描述列表;此外,我還教你如何用用戶輸入的數據來修改這些 cell。雖然示例App 中模擬的表單在真正的 App 中也是可以用的,但在要把它當做一個完整的組件仍然需要我們考慮更多的事情(例如,將 cell 描述列表回寫到文件中)。當然,這已經超出了本文的範圍,我們只是想實現一個展開式的 TableView,讓它的 cell 可以根據需要顯示或隱藏而已,也就是我們最終的實現的那個 App。我希望你能從本教程中發現任何對你有用的東西。當然,你可以設法改進教程中的代碼,或者根據需要進行調整。又到了不得不說再見的時候了,祝你開心,永遠勇於嘗試新的事物!

為便於參考,你可以從 GitHub 下載完整的 Xcode 項目.

譯者簡介:楊宏焱,CSDN 博客專家(個人博客 http://blog.csdn.net/kmyhy)。2009 年開始學習蘋果 iOS 開發,精通 O-C/Swift 和 Cocoa Touch 框架,開發有多個商店應用和企業 App。熱愛寫作,著有多本技術專著,包括:《企業級 iOS 應用實戰》、《iPhone & iPad 企業移動應用開發秘笈》、《iOS8 Swift 編程指南》,《寫給大忙人看的 Swift》(合作翻譯)等。

資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。

blog comments powered by Disqus
訂閲電子報

訂閲電子報

AppCoda致力於發佈優質iOS程式教學,你不必每天上站,輸入你的電子郵件地址訂閱網站的最新教學文章。每當有新文章發佈,我們會使用電子郵件通知你。

已收你的指示。請你檢查你的電郵,我們已寄出一封認證信,點擊信中鏈結才算完成訂閱。

Shares
Share This