SwiftUI 框架

簡介 iOS 16 的新 Layout 協定 讓我們簡單創建自己的容器

在 iOS 16 中,Apple 推出了 layout 協定,希望進一步簡化在 SwiftUI 構建螢幕 layout 的步驟。在這篇文章中,Mark 會帶大家一起來看看這個新協定的實際用途和實作方法,並用它們的 layout 規則創建屬於自己的容器。
簡介 iOS 16 的新 Layout 協定 讓我們簡單創建自己的容器
Photo by Sanju Pandita on Unsplash
簡介 iOS 16 的新 Layout 協定 讓我們簡單創建自己的容器
Photo by Sanju Pandita on Unsplash
In: SwiftUI 框架
本篇原文(標題:The Layout Protocol in iOS 16)刊登於作者 Medium,由 Mark Lucking 所著,並授權翻譯及轉載。

在我開始編寫程式碼的時候,還沒有框架可以使用。如果我們想在螢幕畫一個圓圈,比現在要多編寫幾十行以上的程式碼。隨著科學的發展,抽象 (abstraction) 和框架的概念已經變成了大方向。

今天,我們利用 SwiftUI 框架,只需要幾行程式碼即可。這樣當然很好,但還是會有缺點:抽象層次越高,其靈活性就會相對降低。這是我們需要取捨的地方。

當 Apple 推出 SwiftUI 時,其中一個目標,就是希望簡化過去 20 年 UIKit 的螢幕 layout。SwiftUI 引入了 3 個主要容器來構建螢幕 layout:HStack、VStack、和 ZStack。但有些人已經習慣了在 UIKit 構建螢幕 layout,對於他們來說,SwiftUI 的螢幕 layout 既複雜又凌亂,轉到 SwiftUI 實作要花太多時間了。

Apple 推出了 layout 協定 (protocol) 來嘗試解決這個問題,暴露了這些主要容器的弱點,讓我們可以更改它們的行為方式。在這篇文章中,讓我們一起來看看這個新協定的實際用途和實作方法,並用它們的 layout 規則創建自己的容器。

模版 (Template)

讓我們從這個模版開始,我們可以在裡面切換不同的 layout,並在過程中定義新的 layout:

import SwiftUI

enum Algo: String, CaseIterable, Identifiable {
    case vstack
    case hstack
    case zstack
    
    var layout: any Layout {
        switch self {
            case .vstack: return _VStackLayout()
            case .hstack: return _HStackLayout()
            case .zstack: return _ZStackLayout()
        }
    }
    
    var id: Self { self }
}

struct ContentView: View {
    @State var algo = Algo.hstack
    var body: some View {
        VStack {
            Picker("Algo", selection: $algo) {
                ForEach(Algo.allCases) { algo in
                    Text("\(algo.rawValue)")
                }
            }.pickerStyle(.segmented)
            let layout = AnyLayout(algo.layout)
            layout {
                ForEach(0..<4) { ix in
                    Text("Mark \(ix)")
                        .padding()
                        .background(Capsule()
                            .fill(Color(hue: .init(ix)/10, saturation: 0.8, brightness: 0.8)))
                }
            }.animation(.default.speed(0.2), value: algo)
            .frame(maxHeight: .infinity)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

上面的程式碼構建了一個視圖,我們可以在視圖選擇任何標準 layout,並使用 layout 協定來切換不同 layout。

直到這裡,我們都還沒有用到任何新的功能。

接下來,讓我們添加一些程式碼來建立新的 layout。我們希望實作的,是把 4 個視圖放在一個正方形中。我們需要在 lauout 協定中定義兩個方法。以下是基本的程式碼:

struct CornerLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
            
        let spacing = CGFloat(subviews.count - 1)
        let width:CGFloat = CGFloat(spacing + CGFloat(idealViewSizes.reduce(0) { $0 + Int($1.width) }))
        let height:CGFloat = idealViewSizes.reduce(0) { max($0, $1.height) }
                
        return CGSize(width: width, height: height)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
        {
            var pt = CGPoint(x: bounds.midX, y: bounds.midY)
            
            let places = [UnitPoint.topLeading, UnitPoint.topTrailing, UnitPoint.bottomLeading, UnitPoint.bottomTrailing, UnitPoint.top, UnitPoint.bottom, UnitPoint.leading, UnitPoint.trailing,UnitPoint.center]
            
            if subviews.count > places.count { assert(false,"No more then \(places.count) views supported") }
                
            var c = 0
            for v in subviews {
                v.place(at: pt, anchor: places[c], proposal: .unspecified)
                c += 1
            }
        }
}

現在,讓我們把這些程式碼添加到 Algo 列舉:

這就是抽象的神奇之處 ── 其實沒有 layout 協定,我們還是可以實作出同一個效果,但程式碼就會相對複雜。

不過,視圖現在不是放在 4 個角落。如果我們想把視圖放在角落,可以如此編寫程式碼:

struct CornerLayout: Layout {
    
    let spacing: CGFloat
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
            
        let spacing = spacing * CGFloat(subviews.count - 1)
        let width = spacing + idealViewSizes.reduce(0) { $0 + $1.width }
        let height = spacing + idealViewSizes.reduce(0) { $0 + $1.height }
                
        return CGSize(width: width, height: height)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
        {
            var pt = CGPoint(x: bounds.minX, y: bounds.maxY)
            
            let places = [UnitPoint.topLeading, UnitPoint.topTrailing, UnitPoint.bottomLeading, UnitPoint.bottomTrailing]
            
            if subviews.count > places.count { assert(false,"No more then \(places.count) views supported") }
                
            var c = 0
            for v in subviews {
                switch places[c] {
                case .topLeading: // 0
                    pt = CGPoint(x: bounds.minX, y: bounds.minY)
                    v.place(at: pt, anchor: places[c], proposal: .unspecified)
                case .topTrailing: // 1
                    pt = CGPoint(x: bounds.maxX, y: bounds.minY)
                    v.place(at: pt, anchor: places[c], proposal: .unspecified)
                case .bottomLeading: // 2
                    pt = CGPoint(x: bounds.minX, y: bounds.maxY)
                    v.place(at: pt, anchor: places[c], proposal: .unspecified)
                case .bottomTrailing: // 3
                    pt = CGPoint(x: bounds.maxX, y: bounds.maxY)
                    v.place(at: pt, anchor: places[c], proposal: .unspecified)
                default:
                    assert(false,"neverHappens")
                }
                c += 1
            }
        }
    
    func placeSubviewsY(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
        {
            var pt = CGPoint(x: bounds.minX, y: bounds.minY)
            
            for v in subviews {
                v.place(at: pt, anchor: .topLeading, proposal: .unspecified)
                
                pt.x += v.sizeThatFits(.unspecified).width + spacing
            }
        }
    
}

如此一來,視圖就會分別被放在 4 個角落。我為容器設置了一個邊框,讓我們可以清楚看到切換容器時的變化。

當然,如果你有看過 WWDC20 的相關影片,就會發現他們用來做例子的 layout 更加複雜,是一個圓形的 layout。以下就是實作的程式碼:

struct CircleLayout: Layout {
    var radius: CGFloat
    var rotation: Angle
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        
        let maxSize = subviews.map { $0.sizeThatFits(proposal) }.reduce(CGSize.zero) {
            
            return CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height))
            
        }
        
        return CGSize(width: (maxSize.width / 2 + radius) * 2,
                      height: (maxSize.height / 2 + radius) * 2)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ())
    {
        let angleStep = (Angle.degrees(360).radians / Double(subviews.count))

        for (index, subview) in subviews.enumerated() {
            let angle = angleStep * CGFloat(index) + rotation.radians
            
            var point = CGPoint(x: 0, y: -radius).applying(CGAffineTransform(rotationAngle: angle))
            
            point.x += bounds.midX
            point.y += bounds.midY
            
            subview.place(at: point, anchor: .center, proposal: .unspecified)
        }
    }
}

完成後,視圖會是這樣的:

這篇文章到此為止。我在寫這篇文章的過程中都有所得著,希望你在閱讀時也有同樣的感覺。

本篇原文(標題:The Layout Protocol in iOS 16)刊登於作者 Medium,由 Mark Lucking 所著,並授權翻譯及轉載。
作者簡介:Mark Lucking,編程資歷超過 35 年,熱愛使用及學習 Swift/iOS 開發,定期在 Better ProgrammingThe StartUpMac O’ClockLevel Up Coding、及其它平台上發表文章。
譯者簡介:Kelly Chan-AppCoda 編輯小姐。
作者
AppCoda 編輯團隊
此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。
評論
更多來自 AppCoda 中文版
iOS 18 新API:使用 Navigation Transition 創建 Hero 動畫式過場
SwiftUI 框架

iOS 18 新API:使用 Navigation Transition 創建 Hero 動畫式過場

Apple 的工程師可能早已認識到,許多 iOS 開發者都希望能夠重現 App Store 應用程式中的優雅 Hero 動畫。由於從頭實現這種動畫通常需要耗費大量時間與精力,Apple 在 iOS 18 SDK 中納入了這項功能。 透過這次更新,你現在只需少量的程式碼就能在自己的應用程式中實現類似的動畫過渡效果。這項重大改進讓開發者能夠創造出更具視覺吸引力且流暢的過渡效果,
如何使用 Vision APIs 從圖像中辨識文字
AI

如何使用 Vision APIs 從圖像中辨識文字

Vision 框架長期以來一直包含文字識別功能。我們已經有詳細的教程,向你展示如何使用 Vision 框架掃描圖像並執行文字識別。之前,我們使用了 VNImageRequestHandler 和 VNRecognizeTextRequest 來從圖像中提取文字。 多年來,Vision 框架已經顯著演變。在 iOS 18 中,Vision
iOS 18更新:SwiftUI 新功能介紹
SwiftUI 框架

iOS 18更新:SwiftUI 新功能介紹

SwiftUI的技術不斷演進,每次更新都讓 iOS 應用程式開發變得更加便捷。隨著 iOS 18 Beta 的推出,SwiftUI 引入了多個令人興奮的新功能,使開發者僅需幾行程式碼即可實現出色的效果。 本教學文章旨在探索這個版本中的幾項主要改進,幫助你了解如何運用這些新功能。 浮動標籤列 (Floating Tab Bar)SwiftUI中的標籤視圖(Tab
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。