精通 SwiftUI - iOS 17 版

第 20 章
建立如 Apple Wallet 的動畫與視圖轉場

你使用過 Apple 電子錢包 App 嗎?我相信應該有,在上一章中,我們建立了一個如 Tinder UI 的簡單 App,而本章要做的是建立一個動態 UI,類似於你在電子錢包 App 中看到的 UI。當你在電子錢包 App 中長按信用卡時,則可使用拖曳手勢來重新排列卡片。如果你沒有使用過這個 App,請開啟電子錢包快速瀏覽一下,或者你可以訪問這個URL (https://link.appcoda.com/swiftui-wallet)來了解我們將建立的動畫。

圖 20.1. 建立如電子錢包的動畫與視圖轉場
圖 20.1. 建立如電子錢包的動畫與視圖轉場

在電子錢包 App 中,點擊其中一張信用卡,就會帶出交易歷史紀錄。我們還建立一個類似的動畫,以讓你更了解視圖轉場與水平滾動視圖。

專案準備

為了讓你專注於學習動畫與視圖轉場,你可以從起始專案開始(https://www.appcoda.com/resources/swiftui4/SwiftUIWalletStarter.zip)。這個起始專案已經綁定了所需的信用卡圖片,並且帶有內建的交易歷史紀錄視圖,如果想要使用自己的圖片,請在素材目錄中替換它們,如圖20.2 所示。

圖 20.2 起始專案綁定了信用卡圖片
圖 20.2 起始專案綁定了信用卡圖片

在專案導覽器中,你應該會發現一些 .swift 檔:

  • Transaction.swift - Transaction 結構代表電子錢包 App中的交易。每一筆交易有一個唯一的 ID、交易商、金額、日期與圖示。除了 Transaction 結構之外,作為示範之用,我們還宣告一個測試交易的陣列。
  • Card.swift - 這個檔案包含了 Card 的結構。Card 表示信用卡的資料,包含卡號、類型、有效日期、圖片與客戶姓名,除此之外,你可以在檔案中找到一個測試信用卡的陣列。需要注意的一點是,卡片圖片中不包含任何的個人資訊,而只包含卡片品牌(例如: Visa )。稍後,我們將為信用卡建立一個視圖。
  • TransactionHistoryView.swift - 這是圖 20.1中顯示的交易歷史紀錄,起始專案已經帶有交易歷史紀錄視圖的實作。我們在水平滾動視圖中顯示交易,你之前已經使用過垂直滾動視圖,而建立水平視圖的技巧是在滾動視圖初始化期間傳送 .horizontal 值。請看一下圖 20.3 或 Swift 檔來了解詳細資訊 。
  • ContentView.swift - 這是 Xcode產生的預設 SwiftUI 視圖。
圖 20.3. 使用.horizontal 建立水平滾動視圖
圖 20.3. 使用.horizontal 建立水平滾動視圖

建立卡片視圖

如上一節所述,所有的卡片圖片皆不包含任何個人資訊與卡號。再次開啟素材目錄, 並看一下圖片,每張卡片圖片只具有卡片標誌。我們將很快建立一個卡片視圖,來佈局個人資訊與卡號,如圖 20.4 所示。

圖 20.4. 範例卡片
圖 20.4. 範例卡片

要建立卡片視圖,則在專案導覽器中,右鍵點選 View 群組,然後建立一個新檔案。選取「SwiftUI View」模板,檔案名稱命名為 CardView.swift。接下來,更新程式碼如下:

struct CardView: View {
    var card: Card

    var body: some View {
        Image(card.image)
        .resizable()
        .scaledToFit()
            .overlay(

                VStack(alignment: .leading) {
                    Text(card.number)
                        .bold()
                    HStack {
                        Text(card.name)
                            .bold()
                        Text("Valid Thru")
                            .font(.footnote)
                        Text(card.expiryDate)
                            .font(.footnote)
                    }
                }
                .foregroundColor(.white)
                .padding(.leading, 25)
                .padding(.bottom, 20)

            , alignment: .bottomLeading)
            .shadow(color: .gray, radius: 1.0, x: 0.0, y: 1.0)

    }
}

我們宣告一個 card 屬性來帶入卡片資料。為了在卡片圖片上顯示個人資料與卡號,我們使用 overlay 修飾器,並以垂直堆疊視圖與水平堆疊視圖來佈局文字元件。

要預覽卡片,則更新 CardView_Previews結構如下:

struct CardView_Previews: PreviewProvider {
    static var previews: some View {
        ForEach(testCards) { card in
            CardView(card: card).previewDisplayName(card.type.rawValue)
        }
    }
}

testCards 變數在 Card.swift 中定義,因此我們使用 ForEach 來逐一執行卡片,並呼叫 previewDisplayName 來設定預覽的名稱。Xcode 將如圖 20.5 所示佈局卡片。

圖 20.5. 預覽卡片視圖
圖 20.5. 預覽卡片視圖

建立電子錢包視圖與卡片庫

我們現在已經實作了卡片視圖,讓我們開始建立電子錢包視圖。如果你忘記電子錢包視圖的外觀,請看一下圖 20.6。在進行手勢與動畫之前,我們將先佈局卡片庫。

圖 20.6 錢包視圖
圖 20.6 錢包視圖

在專案導覽器中,你應該會看到 ContentView.swift 檔。刪除它,然後右鍵點選 View 資料夾,以建立一個新檔案。在對話方塊中,選取「SwiftUI View」作為模板,並將檔案命名為 WalletView.swift

如果你預覽 WalletView 或在模擬器中執行 App,Xcode 應該會顯示一個錯誤,因為 ContentView 設定為初始視圖,並且其被刪除了。要修正這個錯誤,則開啟 SwiftUIWallet App.swift,並將 WindowGroup 中下列這行程式碼:

ContentView()

更改為:

WalletView()

切換回 WalletView.swift。當你更改後,將可修正編譯錯誤,現在我們繼續佈局電子錢包視圖。首先,我們從標題列開始,在 WalletView.swift 檔中,為標題列插入一個新結構:

struct TopNavBar: View {

    var body: some View {
        HStack {
            Text("Wallet")
                .font(.system(.largeTitle, design: .rounded))
                .fontWeight(.heavy)

            Spacer()

            Image(systemName: "plus.circle.fill")
                .font(.system(.title))
        }
        .padding(.horizontal)
        .padding(.top, 20)
    }
}

程式碼非常簡單,我們使用水平堆疊來佈局標題與加號圖片。

接下來,是針對卡片庫。首先,在 WalletView 結構中,為信用卡陣列宣告一個屬性:

var cards: [Card] = testCards

為了示範,我們只將預設值設定為 Card.swift 檔中定義的 testCards。要佈局電子錢包視圖,我們同時使用VStackZStack,更新 body 變數如下:

var body: some View {
   VStack {

        TopNavBar()
            .padding(.bottom)

        Spacer()

        ZStack {
            ForEach(cards) { card in
                CardView(card: card)
                    .padding(.horizontal, 35)
            }
        }

        Spacer()
    }
}

如果你在模擬器中執行這個 App 或直接預覽 UI,則應該只看到卡片庫中的最後一張卡片,如圖 20.7 所示。

圖 20.7. 嘗試顯示卡片庫
圖 20.7. 嘗試顯示卡片庫

目前的實作有兩個問題:

  1. 卡片現在彼此重疊了 - 我們需要找出一種方式來展開一副卡片。
  2. Discover卡應該是最後一張卡片 - 在 ZStack中,項目彼此堆疊在一起。放入 ZStack的第一個項目成為最低層,而最後一個項目成為最高層。如果你看一下 Card.swift 中的 testCards 陣列,第一張卡片是 Visa 卡,最後一張卡片是 Discover 卡。

那麼,我們要如何修正這個問題呢?對於第一個問題,我們可以使用 offset修飾器來展開一副卡片。而對於第二個問題,我們顯然可以更改每個 CardViewzIndex,以改變卡片的順序。圖 20.8 說明了這個解決方案是如何工作的。

圖 20.8. 了解zIndex 與偏移量
圖 20.8. 了解zIndex 與偏移量

我們先討論一下 z-index。每張卡片的 z-index 是其在 cards 陣列中索引的負值,如此最後一個項目擁有陣列索引的最大值,也將會有最小的 z-index。對於實際的實作,我們將建立一個單獨的函數來處理 z-index 的計算。在WalletView 中,插入下列的程式碼:

private func zIndex(for card: Card) -> Double {
    guard let cardIndex = index(for: card) else {
        return 0.0
    }

    return -Double(cardIndex)
}

private func index(for card: Card) -> Int? {
    guard let index = cards.firstIndex(where: { $0.id == card.id }) else {
        return nil
    }

    return index
}

這兩個函數可以一起找出給定卡片的正確 z-index。要計算正確的 z-index,我們首先需要取得 cards 陣列中卡片的索引值,index(for:) 函數是為了找出給定卡片的陣列索引值而設計的。當我們有了索引值後,就可以將其變成負值,這就是 zIndex(for:) 函數的作用。

現在,你可以將 zIndex 修飾器加到 CardView,如下所示:

CardView(card: card)
    .padding(.horizontal, 35)
    .zIndex(self.zIndex(for: card))

當你更改後,Visa 卡片應該移到卡片庫的最上方。

接下來,我們修正第一個問題來展開卡片,每張卡片都應偏移一定的垂直距離。而這個距離是使用卡片的索引值來計算的。假設我們將預設的垂直偏移量設定為 50 點,最後一張卡片將會位移 200 點(50×4)。

現在,你應該了解我們將如何展開卡片了,我們來編寫程式碼。在 WalletView 中宣告預設的垂直偏移量:

private static let cardOffset: CGFloat = 50.0

接下來,建立一個名為 offset(for:)的新函數,用於計算給定卡片的垂直偏移量:

private func offset(for card: Card) -> CGSize {

    guard let cardIndex = index(for: card) else {
        return CGSize()
    }

    return CGSize(width: 0, height: -50 * CGFloat(cardIndex))
}

最後,將 offset 修飾器加入到 CardView

CardView(card: card)
    .padding(.horizontal, 35)
    .offset(self.offset(for: card))
    .zIndex(self.zIndex(for: card))

這就是我們使用 offset 修飾器來展開卡片的方式。若是一切正確,你應該會看到如圖 20.9 所示的預覽。

圖 20.9. 展開卡片
圖 20.9. 展開卡片

加入滑入動畫

我們現在已經完成了電子錢包視圖的佈局,是時候加入一些動畫了,我要加入的第一個動畫是滑入動畫。當第一次開啟 App 時,每張卡片都從螢幕最左側滑入,你可能認為這個動畫是不必要的,但是我想藉此機會教你如何建立動畫以及開啟 App 時的視圖轉場。

圖 20.10 滑入動畫
圖 20.10 滑入動畫

首先,我們需要一種觸發過渡動畫的方法, 先在 CardView 的開頭聲明一個狀態變數:

@State private var isCardPresented = false

此變數指示卡片是否應顯示在螢幕上。 在預設的情況下,它設置為false。 稍後,我們將此值設置為 true 以啟動視圖轉換。

每張卡片都是一個視圖。要實作如圖 20.10 所示的動畫,我們需要將 transitionanimation 修飾器加到CardView 上,如下所示:

CardView(card: card)
    .offset(self.offset(for: card))
    .padding(.horizontal, 35)
    .zIndex(self.zIndex(for: card))
    .transition(AnyTransition.slide.combined(with: .move(edge: .leading)).combined(with: .opacity))
    .animation(self.transitionAnimation(for: card), value: isCardPresented)

對於轉場,我們將預設的滑動轉場與移動轉場結合在一起。如前所述,若是沒有 animation 修飾器,則轉場將不會動畫化,這就是為何我們還要加入 animation 修飾器的緣故。由於每張卡片都有自己的動畫,我們建立一個名為 transitionAnimation(for:) 函數來計算動畫。插入下列的程式碼來建立函數:

private func transitionAnimation(for card: Card) -> Animation {
    var delay = 0.0

    if let index = index(for: card) {
        delay = Double(cards.count - index) * 0.1
    }

    return Animation.spring(response: 0.1, dampingFraction: 0.8, blendDuration: 0.02).delay(delay)
}

事實上,所有的卡片都有相似的動畫(即彈簧動畫),差別在於「延遲」(delay )。卡片庫的最後一張卡片將先出現,因此延遲值應該最小。下面是我們如何計算每張卡片的延遲的公式,索引值越小,延遲越長。

delay = Double(cards.count - index) * 0.1

哪我們要如何在 App 啟動的時候觸發轉場?訣竅就是為每一個卡視圖加入id 修飾器。

CardView(card: card)
    .
    .
    .
    .id(isCardPresented)
    .
    .animation(self.transitionAnimation(for: card), value: isCardPresented)

id 的值設定為 isCardPresented 。之後,加入 onAppear 修飾器,並將它加到 ZStack

.onAppear {
    isCardPresented.toggle()
}

ZStack 出現時,我們將 isCardPresented 的值從 false 更改為 true,這將觸發卡片的視圖動畫。應用更改後,在預覽畫布中點墼「Play」按鈕,來進行測試。

更改後,點擊 Play 按鈕在模擬器中測試App,App啟動時就會呈現動畫。

處理點擊手勢及顯示交易紀錄

當使用者點擊卡片時,App 會向上移動所選的卡片,並且顯示歷史交易紀錄。對於其他沒有選到的卡片,它們會被移出螢幕。

要實作這個功能,我們還需要兩個狀態變數。在 WalletView 中宣告這些變數:

@State var isCardPressed = false
@State var selectedCard: Card?

isCardPressed 變數指示是否選擇卡片,而 selectedCard 變數儲存使用者選擇的卡片。

.gesture(
    TapGesture()
        .onEnded({ _ in
            withAnimation(.easeOut(duration: 0.15).delay(0.1)) {
                self.isCardPressed.toggle()
                self.selectedCard = self.isCardPressed ? card : nil
            }
        })
)

要處理點擊手勢,我們可以將上述的 gesture 修飾器加到 CardView,並使用內建的 TapGesture 來捕捉點擊事件。在程式碼區塊中,我們只需切換isCardPressed的狀態,並將目前的卡片設定為 selectedCard 變數。

要將所選的卡片(及其下方的卡片)向上移動,並讓其餘的卡片移出螢幕的話,則更新 offset(for:) 函數如下:

private func offset(for card: Card) -> CGSize {

    guard let cardIndex = index(for: card) else {
        return CGSize()
    }

    if isCardPressed {
        guard let selectedCard = self.selectedCard,
            let selectedCardIndex = index(for: selectedCard) else {
                return .zero
        }

        if cardIndex >= selectedCardIndex {
            return .zero
        }

        let offset = CGSize(width: 0, height: 1400)

        return offset
    }

    return CGSize(width: 0, height: -50 * CGFloat(cardIndex))
}

我們加入了一個 if 語句來檢查卡片是否被選中。如果給定的卡片是使用者選擇的卡片, 則我們將偏移量設定為.zero。對於所選卡片正下方的那些卡片,我們也將向上移動, 這就是為什麼我們將偏移量設定為 .zero。而其餘的卡片,我們將它們移出螢幕,因此垂直偏移量設定為 1400 點

現在,我們已經準備好編寫用於帶出交易歷史紀錄視圖的程式碼。正如一開始所述, 起始專案已經提供這個交易歷史紀錄視圖。因此,你不需要自己建立它。

藉由 isCardPressed 狀態變數,我們可以使用它來確定是否顯示交易歷史紀錄視圖。在 Spacer() 前面插入下列程式碼:

if isCardPressed {
    TransactionHistoryView(transactions: testTransactions)
        .padding(.top, 10)
        .transition(.move(edge: .bottom))
}

在上列的程式碼中,我們設定轉場為 .move,以從螢幕底部帶入視圖,你可以依照自己的喜好來隨意更改它。

圖 20.11 顯示交易歷史紀錄
圖 20.11 顯示交易歷史紀錄

使用拖曳手勢重新排列卡片

現在,來到本章的核心部分,我們來看如何讓使用者以拖曳手勢重新排列卡片庫。首先,我詳細描述此功能的工作原理:

  1. 要開始拖曳動作,使用者必須長按卡片。簡單的點擊將只會帶出交易歷史紀錄。
  2. 當使用者成功按住卡片後,App會將卡片向上移動一點。這是我們想要給使用者的一種回饋,告訴使用者已經準備好可任意拖曳卡片了。
  3. 當使用者拖曳卡片時,使用者應該能在卡片庫中移動它。
  4. 使用者在某個位置放開卡片後,App會更新卡片庫中的所有卡片位置。
圖 20.12. 使用拖曳手勢在卡片庫上移動卡片
圖 20.12. 使用拖曳手勢在卡片庫上移動卡片

處理長按與拖曳手勢

現在你應該了解我們將要做什麼,我們來繼續實作。如果你忘記我們如何使用 SwiftUI 處理手勢,則請回頭閱讀第 17 章,該章已經討論了我們將使用的大多數技術。

首先,在 WalletView.swift 中插入下列的程式碼,來建立 DragState 列舉,以使我們可以輕鬆追蹤拖曳狀態:

enum DragState {
    case inactive
    case pressing(index: Int? = nil)
    case dragging(index: Int? = nil, translation: CGSize)

    var index: Int? {
        switch self {
        case .pressing(let index), .dragging(let index, _):
            return index
        case .inactive:
            return nil
        }
    }
    var translation: CGSize {
        switch self {
        case .inactive, .pressing:
            return .zero
        case .dragging(_, let translation):
            return translation
        }
    }

    var isPressing: Bool {
        switch self {
        case .pressing, .dragging:
            return true
        case .inactive:
            return false
        }
    }

    var isDragging: Bool {
        switch self {
        case .dragging:
            return true
        case .inactive, .pressing:
            return false
        }
    }
}

接下來,在 WalletView 中宣告一個狀態變數,來持續追蹤拖曳狀態:

@GestureState private var dragState = DragState.inactive

如果你之前閱讀過第 17 章,那麼你應該已經知道如何偵測長按與拖曳手勢。然而,這次有點不同,我們需要同時處理點擊手勢、拖曳與長按手勢。而且,如果偵測到長按手勢,則 App 應該忽略點擊手勢。

現在更新 CardViewgesture 修飾器如下:

.gesture(
    TapGesture()
        .onEnded({ _ in
            withAnimation(.easeOut(duration: 0.15).delay(0.1)) {
                self.isCardPressed.toggle()
                self.selectedCard = self.isCardPressed ? card : nil
            }
        })
        .exclusively(before: LongPressGesture(minimumDuration: 0.05)
        .sequenced(before: DragGesture())
        .updating(self.$dragState, body: { (value, state, transaction) in
            switch value {
            case .first(true):
                state = .pressing(index: self.index(for: card))
            case .second(true, let drag):
                state = .dragging(index: self.index(for: card), translation: drag?.translation ?? .zero)
            default:
                break
            }

        })
        .onEnded({ (value) in

            guard case .second(true, let drag?) = value else {
                return
            }

            // 重新排列卡片
        })

    )
)

SwiftUI 讓你可專門組合多種手勢。在上列的程式碼中,我們告訴 SwiftUI 捕捉點擊手勢或長按手勢,換句話說,當偵測到點擊手勢時,SwiftUI 將忽略長按手勢。

點擊手勢的程式碼與我們之前編寫的程式碼完全相同,而拖曳手勢是排列在長按手勢之後。在 updating 函數中,我們將拖曳的狀態、轉場與卡片的索引值設定為之前定義的 dragState 變數。我將不會像第 17 章那樣詳細解釋程式碼。

在拖曳卡片之前,你必須更新 offset(for:) 函數如下:

private func offset(for card: Card) -> CGSize {

    guard let cardIndex = index(for: card) else {
        return CGSize()
    }

    if isCardPressed {
        guard let selectedCard = self.selectedCard,
            let selectedCardIndex = index(for: selectedCard) else {
                return .zero
        }

        if cardIndex >= selectedCardIndex {
            return .zero
        }

        let offset = CGSize(width: 0, height: 1400)

        return offset
    }

    // Handle dragging
    var pressedOffset = CGSize.zero
    var dragOffsetY: CGFloat = 0.0

    if let draggingIndex = dragState.index,
        cardIndex == draggingIndex {
        pressedOffset.height = dragState.isPressing ? -20 : 0

        switch dragState.translation.width {
        case let width where width < -10: pressedOffset.width = -20
        case let width where width > 10: pressedOffset.width = 20
        default: break
        }

        dragOffsetY = dragState.translation.height
    }

    return CGSize(width: 0 + pressedOffset.width, height: -50 * CGFloat(cardIndex) + pressedOffset.height + dragOffsetY)
}

我們加入一段程式碼區塊來處理拖曳。請謹記,只有選定的卡片是可拖曳的。因此, 在更改偏移量之前,我們需要檢查給定的卡片是否為使用者拖曳的卡片。

之前,我們將卡片索引值儲存在 dragState 變數中,因此我們可輕鬆比較給定的卡片索引值與儲存在 dragState 中的卡片索引值,以找出拖曳的卡片。

對於拖曳的卡片,我們在水平與垂直方向上都加入了額外的偏移量。

現在,你可以執行App 來進行測試,長按卡片並任意拖曳,如圖20.13 所示。

圖 20.13. 拖曳卡片
圖 20.13. 拖曳卡片

現在,你應該可以拖曳卡片,不過卡片的 z-index 並沒有相應做更改,例如:如果你拖曳Visa 卡,它總是停留在卡片庫的最上層,我們透過更新 zIndex(for:) 函數來修正它:

private func zIndex(for card: Card) -> Double {
    guard let cardIndex = index(for: card) else {
        return 0.0
    }

    // 卡片的預設 z-index 設定為卡片索引值的負值,
    // 因此第一張卡片具有最大的 z-index
    let defaultZIndex = -Double(cardIndex)

    // 如果它是拖曳的卡片
    if let draggingIndex = dragState.index,
        cardIndex == draggingIndex {
        // 我們根據位移的高度來計算新的 z-index
        return defaultZIndex + Double(dragState.translation.height/Self.cardOffset)
    }

    // 否則我們回傳預設的 z-index
    return defaultZIndex
}

預設的 z-index 仍設定為卡片索引值的負值。對於拖曳的卡片,當使用者在卡片庫上拖曳時,我們需要計算一個新的 z-index。更新後的 z-index 是根據位移的高度與卡片的預設偏移量(即 50 點)來計算。

執行 App 並嘗試再次拖曳 Visa 卡片。現在,當你拖曳卡片時,z-index 會不斷更新。

圖 20.14. 將 Visa 卡移到後面
圖 20.14. 將 Visa 卡移到後面

更新卡片庫

當你放開卡片時,它現在會回到原來的位置,那麼我們如何在拖曳之後重新排序卡片的位置呢?

這裡的技巧是更新 cards陣列的項目,以觸發 UI 更新。首先,我們需要將 cards 變數標記為狀態變數,如下所示:

@State var cards: [Card] = testCards

接下來,我們建立另一個新函數來重新排列卡片:

private func rearrangeCards(with card: Card, dragOffset: CGSize) {
    guard let draggingCardIndex = index(for: card) else {
        return
    }

    var newIndex = draggingCardIndex + Int(-dragOffset.height / Self.cardOffset)
    newIndex = newIndex >= cards.count ? cards.count - 1 : newIndex
    newIndex = newIndex < 0 ? 0 : newIndex

    let removedCard = cards.remove(at: draggingCardIndex)
    cards.insert(removedCard, at: newIndex)

}

當你將卡片拖曳到相鄰的卡片上時,一旦拖曳的位移量大於預設的偏移量,我們便需要更新 z-index。圖 20.15 顯示了拖曳的預期行為。

圖 20.15. 在相鄰卡片之間拖曳萬事達卡
圖 20.15. 在相鄰卡片之間拖曳萬事達卡

這是我們計算更新後的 z-index 的公式:

var newIndex = draggingCardIndex + Int(-dragOffset.height / Self.cardOffset)

一旦我們有了更新後的索引值,最後一步就是透過移除拖曳的卡片,並將其插入新位置,以更新在 cards 陣列中的項目。由於 cards 陣列現在是一個狀態變數,因此 SwiftUI 更新卡片庫且自動渲染動畫。

最後,在「// 重新排列卡片」的下面插入下列這行程式碼來呼叫函數:

withAnimation(.spring()) {
    self.rearrangeCards(with: card, dragOffset: drag.translation)
}

之後,你可以執行 App 來測試它。你已經建立了如電子錢包般的動畫。

本章小結

閱讀完本章後,我希望你對於 SwiftUI 動畫與視圖轉場有更深入的了解。如果你將 SwiftUI 與原來的UIKit 框架進行比較,你會發現 SwiftUI 讓「使用動畫」變得非常容易。你還記得如何為使用者放開拖曳的卡片來渲染卡片動畫嗎?你需要做的是更新狀態變數, 而 SwiftUI 就會處理這些繁重的工作,這就是 SwiftUI 的力量。

為了方便進一步參考,您可以至下列網址下載完整專案: