SwiftUI拾遺

之前有8篇斯坦福CS193p的SwiftUI的筆記, 這篇來自于Ray Wenderlish的祖?zhèn)?strong>by Tutories系列, 記錄一些可以互為補(bǔ)充的內(nèi)容.

Group

Group {
  if configuration.isPressed {
    Capsule()
      .fill(Color.element)
  } else {
    Capsule()
      .fill(Color.element)
      .northWestShadow()
  }
}
  • Group is another SwiftUI container.
  • It doesn't do any layout. It's just useful when you need to wrap code that's more complicated than a single view.
  • 也就是組織代碼用的, 放心使用

模擬器黑暗模式

  • 在視圖debug鍵的右方, 兩個(gè)switch豎向排列的按鈕, 即是Environment Overrides, 打開Apperance開關(guān)
  • 更多built-in EnvironmentValues, Many of these correspond to device user settings like accessibility, locale, calendar and color scheme.

View-level environment value

  • 在視圖容器上設(shè)置.font(.headline), 則所有child view里的文字都會(huì)使用這個(gè)配置
  • 在里層配置則會(huì)覆蓋父級(jí)的配置, 實(shí)現(xiàn)個(gè)性化

GeometrReader

GeometryReader provides you with a GeometryProxy object that has a frame method and size and safeAreaInset properties.

GeometryReader { proxy in 
    ZStack {
        ...
    }
}

同時(shí)預(yù)覽多個(gè)設(shè)備:

Group {
  ContentView(guess: RGB()).previewDevice("iPhone 8")
  ContentView(guess: RGB())
}

ViewModifier

cs193里學(xué)到的是這樣的, 要繼承一個(gè)ViewModifier:

struct Cardify: ViewModifier {
    var isFaceUp: Bool
    
    func body(content: Content) -> some View {
        ZStack {
            Group {
                RoundedRectangle(cornerRadius: 10.0).fill(Color.white)
                RoundedRectangle(cornerRadius: 10.0).stroke(lineWidth: 3.0)
                content  // 正面卡片內(nèi)容
            }.opacity(isFaceUp ? 1.0 : 0.0)
            RoundedRectangle(cornerRadius: 10.0)
            .opacity(isFaceUp ? 0.0 : 1.0) // 反面卡片內(nèi)容
        }
    }
}

然后再擴(kuò)展

extension View {
    func cardify(isFaceUp: Bool) -> some View {
        self.modifier(Cardify(isFaceUp: isFaceUp))
    }
}
  • 其實(shí)是不必要的, 這么寫只是讓你能用view.modifier(Cardify(isFaceUp: true))來使用
  • 你期望的只是view.cardify(isFaceUp: true)的話, 它只是一個(gè)普通的extension, 并不是說一定要modifier才能調(diào)用
  • 用modifier只是為了語(yǔ)義上表示這是一個(gè)modifier, 與extension的用法沒半毛錢關(guān)系, quick demo的話, 并不需要這么寫

順便了解下最完整的形態(tài), 其實(shí)是一個(gè)ModifiedContent方法:

ModifiedContent(
  content: TextField("Type your name...", text: $name),
  modifier: BorderedViewModifier()
)

用style自定義控件

不管是button, 還是label, 都接受一個(gè)modifier來傳入一個(gè)style, 這是一個(gè)繼承ButtonStyleLabelStyle的結(jié)構(gòu)體

// button
struct NeuButtonStyle: ButtonStyle {
  let width: CGFloat
  let height: CGFloat

// 這個(gè)方法在寫make開頭時(shí)會(huì)自動(dòng)感應(yīng)出來, 不需要自己寫
  func makeBody(configuration: Self.Configuration)
  -> some View {
    // button自帶的幾個(gè)子控件都在configuration里, 
    // 取出來組合和自定義即可
    // 比如這里我們只取了label出來
    configuration.label
      .frame(width: width, height: height)
      .background(
        Capsule()
          .fill(Color.element)
          .northWestShadow()
      )
  }
}
// 使用
Button().buttonStyle(NeuButtonStyle(width: 327, height: 48))
  • When you create a custom button style, you lose the default label color (變回黑色) and the default visual feedback when the user taps the button.
  • 恢復(fù)顏色: .foregroundColor(Color(UIColor.systemBlue))
  • 添加動(dòng)效: .opacity(configuration.isPressed ? 0.2 : 1)
// Label
// SwiftUI的Label包含一個(gè)圖標(biāo)和一個(gè)文本(根據(jù)style不同可以只顯示其中一個(gè)), 但是豎向排列很奇怪
// 這里演示把它手動(dòng)用HStack包起來, 而不用默認(rèn)的布局
func makeBody(configuration: Configuration) -> some View {
    // 同樣, 用configuration取出來自定義即可
  HStack {
    configuration.icon
    configuration.title
  }
}

// 用法是一樣的
Label().labelStyle(HorizontallyAlignedLabelStyle())

特殊情況, 下面這種情況不是用的makeBody而是_body方法, 最好找找出處:

// 1. 不是覆蓋makeBody方法, 而是_body方法
// 2. 入?yún)⒉辉偈莄onfiguration, 而是TextField自己(雖然形參還是叫這個(gè))
// 3. 但_body沒法自動(dòng)感應(yīng)出來, 教程也沒說為啥要這樣寫, debug進(jìn)別的原生style, 也是寫makeBody方法的
// 3.1 更神奇的是, makeBody方法也感應(yīng)不出來
// 4. 因此不是從configuration里面取控件, 而是直接對(duì)整個(gè)控件寫modifier
public func _body(
  configuration: TextField<Self._Label>) -> some View {

  return configuration
    .padding(EdgeInsets(top: 8, leading: 16,
                        bottom: 8, trailing: 16))
    .background(Color.white)
    .overlay(
      RoundedRectangle(cornerRadius: 8)
        .stroke(lineWidth: 2)
        .foregroundColor(.blue)
    )
    .shadow(color: Color.gray.opacity(0.4),
            radius: 3, x: 1, y: 2)
}

// 使用
TextField("Type your name...", text: $name).textFieldStyle(KuchiTextStyle())

用ZStack還是background做背景圖

  • ZStack會(huì)根據(jù)子視圖的大小而擴(kuò)展, 如果你添加了一張大于屏幕的圖片, 那么這個(gè)ZStack其實(shí)也大于屏幕了
    • 會(huì)使得一些繪制屬性為"fill"的元素也超出屏幕
  • 如果你添加了其它會(huì)充滿容器的控件(比如TextField會(huì)橫向填充)
  • background modifier則不會(huì)更改其修飾的對(duì)象的大小
  • 這樣如果你需要全屏的background, 你得保證修飾的視圖本身是全屏的(至少能用padding填滿)

Button

  • SwiftUI中Button的定義:struct Button<Label> where Label : View
  • 其中Label是個(gè)泛型, 只需要是個(gè)View就行了

構(gòu)造方法:

init(
  action: @escaping () -> Void,
  @ViewBuilder label: () -> Label
)

可見:

  1. action不是trailing closure, 跟UIKit習(xí)慣相反, SwiftUI中最后一個(gè)closure通常是為了聲明視圖
  2. Label修飾為@ViewBuilder, 意思是返回一些views(默認(rèn)豎向排列)
  • 關(guān)于要點(diǎn)1, 其實(shí)在SwiftUI中也有點(diǎn)妥協(xié), 允許像trailing closuer一樣直接用雙括號(hào)語(yǔ)法, 也不要寫參數(shù)名
  • 但是這樣的話第二個(gè)參數(shù)名就不能省了

觀察下面的兩種寫法, 在SwiftUI中是等效的

Button {
    print("aa")
} label: {
    Text("bb")
}
Button(action: {
    print("aa")
}) {
    Text("bb")
}

child view chose it's own size

Views choose their own size; their parents cannot impose size but only propose instead.

Text("lone text").background(Color.red) // 生成一段文字, 底色是紅色

Text("lone text").background(Color.red)
.frame(width: 150, height: 50, alignment: .center)
.background(Color.yellow)
  1. 生成一段文字, 并用150x50的視圖框起來
  2. 記住, 任何modifier都是新view, 即便是frame, 不要以為這是在為老view設(shè)置frame屬性, 沒這種東西
    2.1 所以, 現(xiàn)在視圖層級(jí)成了 Text - Frame - Root
  3. 這段文字在150x50的空間里用最小的空間布局(這是它的特性, 跟有沒有frame無關(guān), 恰巧這里它的parent是framel罷了)
    3.1 所以, 黃色和藍(lán)色不是完全重合的, 黃色嚴(yán)格修飾的是frame視圖
  4. 如果frame空間小于文字, 還有一個(gè)配置.minimumScaleFactor(0.5), 可以讓文字自動(dòng)縮放, 你給一個(gè)最小比例即可

上述例子如果換成一張巨大的圖片, 則會(huì)無視100x50的空間, 因?yàn)橥耆粔?這就叫chose its own size)

  • 即 it ignores the size proposed by its parent.
  • 除非加一個(gè)修飾.resizable(), 則會(huì)在有限們之間內(nèi)盡可能充滿

所以image和text就是兩個(gè)極端, 一個(gè)最適配, 一個(gè)最不適配.

.frame(maxWidth: .infinity, alignment: .leading)里的.infinity表示有多寬就擺多寬

size原則

padding, stack這樣的修飾器, 是沒有自身的大小的, 完全看child

比較下面兩段代碼


// 左邊短, 右邊長(zhǎng)
HStack {
    Text("A great and warm welcome to Kuchi")
    .background(Color.red)
    Text("A great and warm welcome to Kuchi")
    .background(Color.red)
}
.background(Color.yellow)

// 左邊長(zhǎng), 右邊短
HStack {
    Text("A great and warm welcome to Kuchi")
    .background(Color.red)
    Text("A great and warn welcome to Kuchi")
    .background(Color.red)
}
.background(Color.yellow)
  • 首先, 它會(huì)根據(jù)child個(gè)數(shù)平均分配
  • 第一段左邊比右邊短, 因?yàn)閮啥挝淖忠粯? 左邊文字發(fā)現(xiàn)一半屏幕放不下, 折行后就放下了, 而且折行后用不著一半的空間, 就縮減了空間
    • 右邊文字發(fā)現(xiàn)空間足夠
  • 第二段右邊文字一個(gè)m變成了n, 所以屬于小一點(diǎn)的child, 布局系統(tǒng)優(yōu)先算出它的空間, 發(fā)現(xiàn)也是兩行可以排滿, 于是用了最小的空間, 剩下的給了左邊

通過.layoutPriority(n)可以定義child之間計(jì)算空間的優(yōu)先級(jí) (n: -1 到 1), 以HStack為例

  • 一般是大的先算
  • 但是有小于0的值的話, 則優(yōu)先計(jì)算最小的寬(對(duì)于Text, 基本就是一個(gè)字的寬度)
    • 順便, 最小的寬(一般)也能確定最大的高, 這樣整個(gè)stack的大小可以初步確定
  • 有了最小的寬, HStack會(huì)把低于最高優(yōu)先級(jí)的所有child都賦予這個(gè)寬度, 剩出最多的空間以讓最高優(yōu)先級(jí)的child能優(yōu)先布局
  • 如果最高優(yōu)先級(jí)的child布局后還有空間, 則減出來, 依此類推
size計(jì)算

觀察此圖offered 和 claimed的寬度區(qū)別

LazyList

  1. 循環(huán)里不能用for-in, 崦要用forEach, 因?yàn)樗恢С直磉_(dá)式, 而forEach事實(shí)上就是一個(gè)view, 因而能寫到some view里去
  2. List不能滾動(dòng)起來, 要包到Scroll里去
  3. 需要表頭, 就包到Section里去
  4. 需要固定表頭, 則配置list的pinnedViews入?yún)?/li>
ScrollView {
  LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
    Section(header: header) {
      ForEach(history, id: \.self) { element in
        createView(element)
      }
    }
  }
}

Functional user interface

  • Being functional, rendering now always produces the same result given the same input,
  • and changing the input automatically triggers an update. Connecting the right wires pushes data to the user interface, rather than the user interface having to pull data.

SwiftUI:

  • Declarative: 聲明式的UI
  • Functional: 相同輸入產(chǎn)生相同輸出, 完全取決于狀態(tài)
  • Reactive: 響應(yīng)式

State是什么

一個(gè)簡(jiǎn)單程序:

struct ContentView: View {
    @State private var isTapped = false
    // 1. var ctr = 0
    /* 2. 包到Struct里去
    struct mystruct {
      var ctr = 0
    }
    var state = mystruct()
    3. 改成class, 略
    4. 用一個(gè)包裝器
    class Box<T> {
      var wrappedValue: T
      init(initialValue value: T) {
          self.wrappedValue = value
      }
    }
    var state = Box<Int>(initialValue: 0)
    5. 用State
    var state = State<Int>(initiaValue: 0) // 注意, State是一個(gè)struct, 比demo里用class的box要復(fù)雜
    6. 換個(gè)寫法
    @State var state = 0
    */

    
    var body: some View {
        Button(action: {
          // 1. self.ctr += 1 // 報(bào)錯(cuò), 因?yàn)椴荒軓腷ody內(nèi)部改變屬性的狀態(tài)
          // 2. self.state.ctr += 1 // 報(bào)錯(cuò), struct仍然是value type
          // 3. struct變成class, 不報(bào)錯(cuò)了, 但是顯示的文字沒有變化
          // 但是ctr的值確實(shí)變了, 因?yàn)橹羔樦赶虻膶?duì)象還是可變的
          // 如果這個(gè)視圖有別的控件觸發(fā)了這個(gè)視圖的重繪, 會(huì)發(fā)現(xiàn)UI確實(shí)變了
          // 4. self.state.wrappedValue += 1 // 不報(bào)錯(cuò), 但是顯示的文字沒有變化
          // 但是與3一樣, 能在別的UI刷新后自身也刷新, 其實(shí)原理是一樣的
          // 5. self.state.wrappedValue += 1 // 能響應(yīng)點(diǎn)擊事件并刷新UI了
          // 6. 最終寫法, 所以6就是5的語(yǔ)法糖而已
          self.state += 1
        }) {
            // Text("\(self.ctr)")
            // Text("\(self.state.ctr)")
            // Text("\(self.state.wrappedValue)")
            Text("\(self.state)")
          }
        }
}

綜上, State就跟我們模擬的Box一樣, 封裝了一個(gè)不可變對(duì)象, 但本身是一個(gè)class(不是的, 見下方注釋), 所以能在view的body被改變它的成員變量(主要就是wrappedValue), 而且在body被改變時(shí), 會(huì)自動(dòng)觸發(fā)UI的更新(這個(gè)是我們用Box)沒有模擬出來的
即:

  • @State修飾的變量, 是一個(gè)可觀察對(duì)象(能invalidate view)
  • @State修飾的變量, 是不可變的(所以由State出面來包裝)
  • 當(dāng)它的值改變時(shí), 會(huì)自動(dòng)觸發(fā)UI的更新
  • 它會(huì)生成State<T>的代碼
  • 并生成一個(gè)同名的帶下劃線的變量
    • 也就是說, 你可以用self.state來使用, 也可以用self._state.wrappedValue來使用

官方定義: A property wrapper type that can read and write a value managed by SwiftUI.

SwiftUI manages the storage of any property you declare as a state. When the state value changes, the view invalidates its appearance and recomputes the body. Use the state as the single source of truth for a given view.

注意, Demo中的Box需要是一個(gè)對(duì)象, 但State是一個(gè)struct, 之所以能對(duì)struct的State進(jìn)行變更, SwiftUI還做了別的工作.

Binding

SwiftUI希望你只有一份數(shù)據(jù), 所有的地方都去讀取它, 而不是復(fù)制它的值自己去用, 這樣才能做到這個(gè)值改變的時(shí)候, 觀察它的對(duì)象也能更新. 顯然值類型就做不到這一點(diǎn)了, (事實(shí)上Binding, State是特殊處理過的值類型)

  • In SwiftUI, components don’t own the data — instead, they hold a reference to data that’s stored elsewhere.
  • A binding is a two-way connection between a property that stores data, and a view that displays and changes the data.
  • A binding connects a property to a source of truth stored elsewhere, instead of storing data directly.
  • @StatewrappedValue來讀封裝的值, 但要用projectdValuebindview和數(shù)據(jù)源, 這樣它接受來自UI的變更, 并且把數(shù)據(jù)源更新
  • 要傳遞一個(gè)狀態(tài)對(duì)象(即不在本類定義, 而是別的地方定義的), 則要用@Binding, 因?yàn)?code>State仍然是一個(gè)值類型, 通過特殊處理, 能改變它的值了, 但是仍然會(huì)在傳遞的時(shí)候復(fù)制, 而@Binding則通過構(gòu)造方法傳入gettersetter的方式支持了讀和寫都對(duì)應(yīng)同一個(gè)數(shù)據(jù)源

observation

  1. 值類型如struct改變?nèi)魏我粋€(gè)屬性都是一個(gè)全新的實(shí)例, 如果對(duì)它進(jìn)行觀察, 那所有的觀察者都會(huì)重繪, 哪怕沒有變動(dòng)的屬性
  2. 引用類型只有改變了指針才算改變, 對(duì)它進(jìn)行觀察則跟蹤不到屬性的變化

為了解決上面的問題, 引入了新的類型, 實(shí)現(xiàn)三個(gè)方向:

  1. 是一個(gè)引用類型
  2. 是一個(gè)可觀察的類型
  3. 能定制可觀察的屬性

Sharing in the environment

  • Using environmentObject(_:), you inject an object into the environment.

  • Using @EnvironmentObject, you pull an object (actually a reference to an object) out of the environment and store it in a property.

  • 注入后, 所有的子級(jí)及嵌套都能看到, 但父級(jí)及以上看不到

  • 如果你注入的是未命名的對(duì)象, 則取出來的時(shí)候用類型即可

    • 注入: .environmentObject(ChallengesViewModel())
    • 取出: @EnvironmentObject var challengesViewModel: ChallengesViewModel
  • When you want a view to own an observable object, because it conceptually belongs to it, your tool is @StateObject.

  • When an observable object is owned elsewhere, either @ObservedObject or @EnvironmentObject are your tools — choosing one or the other depends from each specific case.

一些環(huán)境變量

@Environment(\.verticalSizeClass) var verticalSizeClass

if verticalSizeClass == .compact { // 橫屏, 因?yàn)関ertical compact的話, 就是豎向高度不夠的意思

} else {}

// 你也可以隨時(shí)改變環(huán)境變量
view.environment(\.verticalSizeClass, .compact)

注入命名環(huán)境變量

上面說的是未命名的, 你只能注入一個(gè)對(duì)象, 對(duì)類型取出來, 那么像verticalSizeClass這樣的用keyPath類似的語(yǔ)法取出來的話, 這么做:

  1. 一個(gè)服從EnvironmentKey的結(jié)構(gòu)體(它只有一個(gè)defaultValue)
  2. EnvironmentValues的擴(kuò)展里, 增加你要取的名字(keypath)的getter/setter
// 1.
struct QuestionsPerSessionKey: EnvironmentKey {
  static var defaultValue: Int = 5
}

// 2.
extension EnvironmentValues {
  var questionsPerSession: Int { // questionsPerSession 就是你要取的名字
    get { self[QuestionsPerSessionKey.self] }
    set { self[QuestionsPerSessionKey.self] = newValue }
  }
}

// 注入
someview().environment(\.questionsPerSession, 15)

// 使用(在someview里)
@Environment(\.questionsPerSession) var questionsPerSession

但是根據(jù)這個(gè)文檔, 自定義環(huán)境變量更簡(jiǎn)單了, 使用Entry()宏即可

extension EnvironmentValues {
    @Entry var myCustomValue: String = "Default value" // 在我的15.4的xcode報(bào)錯(cuò)
}


extension View {
    func myCustomValue(_ myCustomValue: String) -> some View {
        environment(\.myCustomValue, myCustomValue)
    }
}

Controllers

DatePicker(
  "",
  selection: $dailyReminderTime,
  displayedComponents: .hourAndMinute
).datePickerStyle()  
// CompactDatePickerStyle() -> (iOS default), 兩個(gè)button, 點(diǎn)擊后展開日歷
// WheelDatePickerStyle
// GraphicalDatePickerStyle 日歷, Mac下有個(gè)時(shí)鐘
// FieldDatePickerStyle Mac, 文本框
// StepperFieldDatePickerStyle Mac, 可步進(jìn) (Mac default)

Toggle("Daily Reminder", isOn: $dailyReminderEnabled)
// 等同于如下, 如果有額外操作, 需要這樣展開
Toggle("Daily Reminder", isOn:
  Binding(
    get: { dailyReminderEnabled },
    set: { newValue in
      dailyReminderEnabled = newValue
      // other biz
    }
  )
)

ColorPicker(
  "Card Background Color",
  selection: $cardBackgroundColor
)

// picker style: https://apple.co/3nyViIG
// 注意每個(gè)選項(xiàng)的label和id的傳入方式
Picker("", selection: $appearance) {
  Text(Appearance.light.name).tag(Appearance.light)
  Text(Appearance.dark.name).tag(Appearance.dark)
  Text(Appearance.automatic.name).tag(Appearance.automatic)
}.pickerStyle(SegmentedPickerStyle()) // 默認(rèn)是個(gè)list

// 如果是caseiterable:
ForEach(Appearance.allCases) { appearance in
  Text(appearance.name).tag(appearance)
}

TabView

TabView { // tabview
  SettingsView() // 具體頁(yè)面
    .tabItem({ // 配置tab圖標(biāo)
      VStack {
        Image(systemName: "gear")
        Text("Settings")
      }
    })
    .tag(2)
}
.accentColor(.orange) // 高亮色

UserDefaults / App storage

@AppStorage("numberOfQuestions") var numberOfQuestions = 6

// 下面這種寫法是只讀的, 至于為什么也要初始化一下, 看后面有沒有解答
@AppStorage("numberOfQuestions")
private(set) var numberOfQuestions = 6

以下類型能存到UserDefaults

  1. Basic types: Int, Double, String, Bool
  2. Composite types: Data, URL
  3. adopting RawRepresentable

這是你支持自定義類型存入的方法:

  • Make the type RawRepresentable
  • Use a shadow property

RawRepresentbale

  • 如果一個(gè)枚舉的類型被定義為基礎(chǔ)類型, 那么它自動(dòng)服從了RawRepresentable
  • 別的類型怎么實(shí)現(xiàn)RawRepresentable尚未講到

Shadow Property

比如一個(gè)Date類型, 是存不進(jìn)的, 我們?cè)黾右粋€(gè)Double類型

@AppStorage("dailyReminderTime") var dailyReminderTimeShadow: Double = 0

// 上面實(shí)例化過一個(gè)DatePicker, 我們?cè)趕etter里增加一個(gè)轉(zhuǎn)換
DatePicker(
  "",
  selection: Binding(
    get: { dailyReminderTime },
    set: { newValue in
      dailyReminderTime = newValue
      dailyReminderTimeShadow = newValue.timeIntervalSince1970 // date -> double
      configureNotification()
    }
  ),
  displayedComponents: .hourAndMinute
)

// 在什么時(shí)候轉(zhuǎn)回日期? .onAppear在每次顯示的時(shí)候調(diào)用
.onAppear {
  dailyReminderTime = Date(timeIntervalSince1970: dailyReminderTimeShadow)
}

這么看來其實(shí)沒什么新語(yǔ)法上的支持, 就是你只存UserDefaults支持的類型就好了, 由開發(fā)者自己來做這個(gè)轉(zhuǎn)化的意思

Gesture

@GestureState會(huì)在手勢(shì)完成后自動(dòng)重置, @State不會(huì)

@GestureState var isLongPressed = false

let longPress = LongPressGesture()
  .updating($isLongPressed) { value, state, transition in
    state = value // 注意, binding value to state(你updating誰誰就是state)
  }
  .simultaneously(with: drag)

上面演示了綁定兩個(gè)手勢(shì), 但如果是在不同的視圖內(nèi)的兩個(gè)手勢(shì)呢?

.gesture(TapGesture()
  ...
)
// 改為
.simultaneousGesture(TapGesture()
  ...
)

Navigation

  • SwiftUI navigation organizes around two styles: flat and hierarchical.
  • 分別對(duì)應(yīng)TabViewNavigationView
  • TabView
    • tab圖標(biāo)只支持文字, 圖片或者圖片+文字(不需要用VStack), 其它方式都會(huì)顯示為空占位
    • 所以對(duì)圖片用modifier(比如旋轉(zhuǎn))也不行
    • 假如要記下當(dāng)前tab: “@AppStorage("FlightStatusCurrentTab") var selectedTab = 1
      • 使用TabView(selection: $selectedTab)會(huì)用指定的tab來初始化, 并且在tab切換的時(shí)候更新新的值
      • 更新的值是每個(gè)view的tag
    • tabViewStyle(_:)可以改變轉(zhuǎn)場(chǎng)方式
  • NavigationView
    • navigationBarTitle(_:)定義當(dāng)前頁(yè)標(biāo)題
    • NavigationLink(destination:)導(dǎo)航
      • 導(dǎo)航鏈接用文字的話在第一參數(shù), 用view的話是第二參數(shù), 服從SwiftUI的規(guī)范
    • 小屏NavigationView默認(rèn)用stack堆疊, 大屏默認(rèn)用split分屏
      • 可以用.navigationViewStyle(StackNavigationViewStyle())修改
    • 環(huán)境變量要加給NavigationView, 而不是任何一個(gè)子view

List

ForEach

List之前我們先看看ForEach

  • ForEach: provide datas output views (via clsoure)
    • It doesn't provide any structure
      • so you should place it into a VStack, and a Scroll
    • 需要指定一個(gè)Hashable的鍵(Swift的StringInt就可以)
      • 如果整個(gè)對(duì)象是Hashable的, 那么\.self也行
      • 如果整個(gè)對(duì)象是Identifiable(from Swift5.1)的, 那么可以忽視掉這個(gè)參數(shù)
    • 自行橫向或縱向stack是沒有內(nèi)存優(yōu)化的, 有多少實(shí)例化多少
      • Lazy版本就是解決這個(gè)的(首次appears實(shí)例化, 但不再會(huì)消失和復(fù)用)
      • Lazy版本在垂直方向上是鋪滿空間的, 既如果是VStack, 那么橫向是鋪滿的
    • ScrollView需要包一層ScrollViewReader來增強(qiáng)功能, 比如滾動(dòng)定位
      • 也適用于Lazy版本, 即你能滾動(dòng)到還沒有渲染的元素去

ScrollViewReader demo

ScrollViewReader { scrollProxy in
  ScrollView {
    LazyVStack {
      ForEach(flights) { flight in
        NavigationLink(
          destination: FlightDetails(flight: flight)) {
          FlightRow(flight: flight)
        }
      }
    }
  }
  .onAppear {}
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
      scrollProxy.scrollTo(nextFlightId, anchor: .center) // 用你for-each時(shí)候的id定位
  }
}

其實(shí)這種延遲0.05秒再運(yùn)行的例子是很壞的實(shí)踐, 因?yàn)檫@個(gè)0.05其實(shí)并沒有任何保證

List

上面的例子用List改造一下

ScrollViewReader { scrollProxy in
  List(flights) { flight in // 幫助做了Scroll+LazyVStack
    NavigationLink(
      destination: FlightDetails(flight: flight)) {
      FlightRow(flight: flight)
    }
  }.onAppear {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
      scrollProxy.scrollTo(nextFlightId, anchor: .center)
    }
  }
}
  • ForEach allows you to iterate over almost any collection of data and create a view for each element.
  • List acts much as a more specific case of ForEach to display rows of one-column data

層級(jí)List, 看示例, 簡(jiǎn)單到犯規(guī)(前提是結(jié)構(gòu)是遞歸的),


層級(jí)list

當(dāng)然要自定義還是有點(diǎn)功夫的, 至少目錄和內(nèi)容的行為是不可能一致的, 所以你在List的view builder里, 至少要做一個(gè)if-else

分組和更多個(gè)性化, 就不能用上面的全自動(dòng)代碼了, 改一下:

List {
    ForEach(data) { item in
        Section(header: Text(item.label), footer: HStack {
            Spacer()
            Text("footer")
        }) {
            ForEach(item.children!) { child in
                Text(child.label)
            }
        }
    }
}.listStyle(InsetGroupedListStyle())
  1. List后不跟數(shù)據(jù), 而是自行ForEach >>> 最終還得靠ForEach
  2. 想要分組, 就再跟上Section, 這樣就把title和children分離了
  3. 標(biāo)題, 頁(yè)腳等, 屬于Section的內(nèi)容

Grid

  • LazyVGridLayHGrid, 本質(zhì)上就是一個(gè)主軸和交叉軸分別應(yīng)用LazyVStackLazyHStack
var awardColumns: [GridItem] {
  [GridItem(.flexible(minimum: 150)), // .fixed, .flexible
  GridItem(.flexible(minimum: 150))] // 表示了能做多寬做多寬
}

LazyVGrid(columns: awardColumns) {
  ForEach(awardArray, id: \.self) { award in
    NavigationLink(destination: AwardDetails(award: award)) {
      AwardCardView(award: award)
        .foregroundColor(.black)
        .frame(width: 150, height: 220) // view本身限制了150寬,與column配置不沖突
    }
  }
}
  • 上例中, 用最小值150 + 自定義值150 限定了cell的寬度, 結(jié)果跟直接用.fixed(150)是一致的
  • 但是這種寫法就能支持不同cell有不同的寬度
  • 如果你設(shè)置了最大寬, 但自定義值大于最大值怎么辦?
    • 元素會(huì)保持設(shè)置的大小, 但是布局系統(tǒng)會(huì)按griditem的配置來布局
    • 內(nèi)容是縮放還是裁剪, 取決于aspectRatio配置
GridItem(.flexible(minimum: 150, maximum: 170))
card.aspectRatio(0.67, contentMode: .fit)

思考: columns(HGrid中則是rows)數(shù)組的個(gè)數(shù)決定了每一行擺放的元素個(gè)數(shù), 那么如果需要不定個(gè)數(shù)的自動(dòng)折行怎么實(shí)現(xiàn)?
[GridItem(.adaptive(minimum: 150, maximum: 170))]

但是實(shí)測(cè)不盡如人意:


adaptive columns
  1. 注意到重疊了沒? 不知道為什么它一排總要放5個(gè)
  2. 而且每行數(shù)量是一樣的
  3. 通過更改min/max的大小, 一行的個(gè)數(shù)也會(huì)增減, 可見應(yīng)該是由第一行的個(gè)數(shù)決定的

說明.adaptive并不能像CollectionViewFlowLayout一樣計(jì)算每個(gè)元素的位置

原因是grid畢竟是grid, 它是一個(gè)表格, 不可能每行的列數(shù)不一樣, 我想要的流式布局, 一般理解為"可換行的HStack", 以下有幾個(gè)三方庫(kù)和幾個(gè)so討論可以借鑒下:

嵌套使用

如果你寫了一個(gè)grid, 想給它分組怎么辦? 之前是一個(gè)LazyVGrid里直接添加N個(gè)View, 現(xiàn)在用Section分一下組就行

struct AwardGrid: View {
  // 1
  var title: String
  var awards: [AwardInformation]

  var body: some View {
    // 2
    Section(
      // 3
      header: Text(title)
        .font(.title)
        .foregroundColor(.white)
    ) {
      // 4
      ForEach(awards, id: \.self) { award in
        NavigationLink(
          destination: AwardDetails(award: award)) {
          AwardCardView(award: award)
            .foregroundColor(.black)
            .aspectRatio(0.67, contentMode: .fit)
        }
      }
    }
  }
}
// 使用
LazyVGrid(columns: awardColumns) {
  AwardGrid(
    title: "Awarded",
    awards: activeAwards
  )
  AwardGrid(
    title: "Not Awarded",
    awards: inactiveAwards
  )
}
  1. AwardGrid只是封裝出來了, 本質(zhì)上還是一個(gè)Section, 它的有效元素仍然是一堆View
  2. 所以就把原始結(jié)構(gòu)由views變成了sections, LazyVGrid的所有屬性會(huì)透過section傳給view來布局, 而不是去布局section
  3. 但是section就是簡(jiǎn)單地從上到下排列, 可以理解為LazyVStack

教程里有這么一句話, 但沒有實(shí)例: You can mix different types of grid items in the same row or column.
如何能做到.fixed, .flexible.adaptive作用在同一行的?

Sheets & Alert Views

  • 是在導(dǎo)航邏輯之外的獨(dú)立UI
  • 目的就是阻斷用戶的操作, 引起用戶必要的注意
  • SwiftUI provides two ways to display a modal, both based on a @State variable in the view.
    • 一種是Bool值, 為True就顯示
    • 一種是為non nil就顯示
  • 共提供了四種modal:
    1. sheet
    2. alert
    3. action sheet (deprecated) -> confirmationDialog
    4. popover (大屏才有意義, 小屏直接全屏sheet就好了)
// sheet
Button(
  action: {
    isPresented.toggle()
  }, label: {
    Text("toggle sheet")
  })
  .sheet(
    isPresented: $isPresented,
    onDismiss: {
      print("Modal dismissed. State now: \(self.isPresented)")
    },
    content: {
        EmptyView()
    }
  )
  • 如果是第一次使用, 那你只能習(xí)慣這種用法, 在很久以前的bootstrap就用了這種方式來做交互
  • sheet沒法獨(dú)立定義在哪供你show出來, 只能用modifier的方式掛在一個(gè)視圖后面
  • 但是掛在任一視圖后面就行了, 不是一定要像demo那樣跟在觸發(fā)的按鈕后面
    • 其實(shí)你也能猜到, 任何地方都吧可以觸發(fā)isPresented的變化
  • You can create a ne?w navigation view on the modal, but it makes an entirely new navigation view stack.
// alert
Button("toggle alert") {
  isPresented.toggle()
}
.alert(
  isPresented: $isPresented {
    Alert(
      title: Text("Alert"),
      message: Text("This is an alert"),
      dismissButton: .default(Text("OK"))
    )
  }
)

用法是一樣的, 你只需要把它掛到一個(gè)view語(yǔ)句后面, 聲明有這個(gè)么視圖即可

// action sheet
Button("toggle action sheet") {
  isPresented.toggle()
}
.actionSheet(
  isPresented: $isPresented,
  buttons: [
    .default(Text("Default")),
    .destructive(Text("Destructive")),
    .cancel(Text("Cancel"))
  ]
)
  • actionSheetbuttons是一個(gè)數(shù)組, 你可以定義多個(gè)按鈕, 每個(gè)按鈕可以定義Textstyle
  • style有三種, default, destructive, cancel, 其中cancel是默認(rèn)的, 不用定義
  • defaultdestructive的區(qū)別是顏色, destructive是紅色, default是藍(lán)色

但是actionSheet已經(jīng)過時(shí)了, 用confirmationDialog

// confirmation dialog
Button("toggle action sheet") {
  isAction.toggle()
}
.confirmationDialog("action", isPresented: $isAction, titleVisibility: .visible) {
    Button("one"){}
    Button("two"){}
    Button("cancel", role: .cancel){}
    Button("delete", role: .destructive){}
}
  • confirmationDialogactions閉包里返回一個(gè)數(shù)組, 數(shù)組里是多個(gè)Button
  • 參考這篇文章看個(gè)性化的sheet action
    custom action sheet
// popover
Button("toggle popover") {
  isPresented.toggle()
}
.popover(
  isPresented: $isPresented,
  attachmentAnchor: .point(.bottom, alignment: .center),
  arrowEdge: .bottom,
  content: {
    Text("Popover") // popover的視圖是自定義的, 就是一個(gè)小彈窗而已
  }
)

Drawing & Custom Graphics

  • One of the basic drawing structures in SwiftUI is the Shape
  • A shape is a special type of view.
  • By default, SwiftUI renders graphics and animations using CoreGraphics.

如果因?yàn)槔L制造成效率低下:
you can use the drawingGroup() modifier on your view. This modifier tells SwiftUI to combine the view’s contents into an offscreen image before the final display. (Metal的特性)

  • drawingGroup() modifier only works for graphics — shapes, images, text, etc.
  • offscreen composition adds overheard and results in slower performance for simple graphics

Using GeometryReader

The GeometryReader container provides a way to get the size and shape of a view from within it.

HStack {
  Text("\(history.day) day(s) ago")
    .frame(width: 110, alignment: .trailing)
  // 只在需要的時(shí)候才包GeometryReader, 沒必要包在最外層
  GeometryReader { proxy in
    Rectangle()
      .foregroundColor(history.delayColor)
      .frame(width: minuteLength(history.timeDifference, proxy: proxy))
      .offset(x: minuteOffset(history.timeDifference, proxy: proxy))
  }
}
.padding()
.background(
  Color.white.opacity(0.2)
)

上例是一個(gè)bar chart的demo, 左邊text, 右邊矩形做bar, 為了讓每個(gè)值對(duì)應(yīng)成屏幕上的像素點(diǎn)(類似于比例尺), 就需要知道容器的真實(shí)大小.

有這么句話: There's no need to wrap the two elements inside a ZStack when using shapes inside a GeometryReader.
書中的例子是給bar上加刻度條, 因?yàn)槭窃?code>GeometryReader里, 給了offset和frame就行了, 都會(huì)在bar上面繪制, 個(gè)人認(rèn)為就是在GeometryReader的size里繪制的意思, 因?yàn)槭抢L制, 所以就無所謂ZStack了, 關(guān)心的只有繪制的坐標(biāo).

Gradients

LinearGradient(gradient: Gradient(colors: [.red, .yellow]), startPoint: .leading, endPoint: .trailing)
  • LinearGradient是線性漸變, RadialGradient是徑向漸變
  • 你需要構(gòu)造一個(gè)Gradient對(duì)象, 然后傳給LinearGradientRadialGradient, 等于一個(gè)是配置顏色, 一個(gè)是配置如何用這些顏色

Shapes

  • Rectangle
  • Circle
  • Ellipse
  • RoundedRectangle
  • Capsule
    以下這些shape是AI自動(dòng)生成的, 我保留下來以后看看有沒有生造出一些shape出來
  • Triangle
  • RegularPolygon
  • Polygon
  • Arc
  • BezierPath
  • Path
  • Shape
  • InsettableShape
  • ShapeStyle
  • PathStyle
  • ShapeView
  • ShapeViewStyle
  • ShapeStyleView

圓角邊框

要實(shí)現(xiàn)圓角邊框, 你能用到的方式有:

  • CornerRadius + overlayRoundedRectangle.stroke
  • CornerRadius + border
  • ClipShapeRoundedRectangle + overlayRoundedRectangle.stroke

其實(shí)就是圓角, 你是選擇ClipShape還是CornerRadius; 邊框, 你是選擇Border還是Overlay.

Paths

因?yàn)橛玫亩际?code>CoreGraphics, 語(yǔ)法都差不多:

GeometryReader { proxy in
  let radius = min(proxy.size.width, proxy.size.height) / 2.0
  let center = CGPoint(x: proxy.size.width / 2.0, y: proxy.size.height / 2.0)
  var startAngle = 360.0
  ForEach(pieElements) { segment in
    let endAngle = startAngle - segment.fraction * 360.0
    Path { pieChart in
      pieChart.move(to: center)
      pieChart.addArc(
        center: center,
        radius: radius,
        startAngle: .degrees(startAngle),
        endAngle: .degrees(endAngle),
        clockwise: true
      )
      pieChart.closeSubpath()
      startAngle = endAngle
    }
    .foregroundColor(segment.color)
  }
}

連續(xù)畫折線的話, 可以直接傳入一個(gè)坐標(biāo)數(shù)組

Path { path in
    path.addLines([
        CGPoint(x: 0, y: 128),
        CGPoint(x: 142, y: 128),
        CGPoint(x: 142, y: 70)
      ])
}.stroke(Color.blue, lineWidth: 3.0)

Animations & View Transitions

  • In SwiftUI, you just tell SwiftUI the type of animation, and it handles the interpolation for you.
Image()
.rotationEffect(.degrees(showTerminal ? 90 : -90)) // 沒有動(dòng)畫
.animation(.linear(duration: 1.0)) // 對(duì)上面的effect進(jìn)行動(dòng)畫
.animation(Animation.default.speed(0.33)) // 減慢速度

'animation' was deprecated in iOS 15.0: Use withAnimation or animation(_:value:) instead.

Eased animations

  • Animation.default就是easeInOut(默認(rèn)時(shí)間是0.35秒)
  • If you need fine control over the animation curve's shape, you can use the timingCurve(_:_:_:_) type method.
    • 四個(gè)參數(shù)就是塞塞爾曲線的兩個(gè)控制點(diǎn)的坐標(biāo), 范圍是0到1

Spring animations

  • eased animations是單向的, 在快結(jié)束的時(shí)候加點(diǎn)bounce, 就叫sping
.animation(
  .interpolatingSpring(
    mass: 1,
    stiffness: 100,
    damping: 10,
    initialVelocity: 0
  )
)
  • mass: Controls how long the system "bounces".

  • stiffness: Controls the speed of the initial movement.

  • damping: Controls how fast the system slows down and stops.

  • initialVelocity: Gives an extra initial motion.

  • 質(zhì)量越大拔恰,動(dòng)畫持續(xù)的時(shí)間越長(zhǎng),在端點(diǎn)兩側(cè)彈跳的距離越遠(yuǎn)巩梢。質(zhì)量越小提揍,停止的速度越快刁标,每次彈跳經(jīng)過端點(diǎn)的距離也越短。

  • 增加剛度會(huì)使每次彈跳都更遠(yuǎn)地越過端點(diǎn),但對(duì)動(dòng)畫長(zhǎng)度的影響較小妆档。

  • 增加阻尼會(huì)使動(dòng)畫更快平滑和結(jié)束。

  • 增加初速度會(huì)使動(dòng)畫彈跳得更遠(yuǎn)虫碉。負(fù)的初速度會(huì)使動(dòng)畫向相反方向移動(dòng)贾惦,直到克服初速度為止

.animation(
  .spring(
    response: 0.55, // 定義一個(gè)周期的時(shí)長(zhǎng)
    dampingFraction: 0.45, // 控制彈力的停止速度, 0是不停止, 1等于彈不動(dòng)
    blendDuration: 0
  )
)

"blendDuration "參數(shù)用于控制不同動(dòng)畫之間的混合過渡長(zhǎng)度。只有在動(dòng)畫過程中更改參數(shù)或組合多個(gè)彈簧動(dòng)畫時(shí)才會(huì)使用該參數(shù)敦捧。如果值為零须板,則會(huì)關(guān)閉混合功能。

  • 如果你又加了個(gè)effect: .scaleEffect(showTerminal ? 1.5 : 1.0), 那么這個(gè)scaleEffect也會(huì)被動(dòng)畫化,
  • 你想要立刻生效, 不要?jiǎng)赢? 那就得注意先后, 把不需要?jiǎng)赢嫷膃ffect寫在前面, 然后跟上.animation(nil)
  • 如果你把nil動(dòng)畫改成了另一個(gè)動(dòng)畫, 比如.animation(.linear(duration: 1.0)), 那么兩個(gè)effect就應(yīng)用了各自的動(dòng)畫simultaneously and blend smoothly

也就是說, 為每個(gè)effect做一個(gè)animation

Animating multiple properties

  • 如果你想讓兩個(gè)屬性同時(shí)動(dòng)畫化, 那么需要用withAnimation來包裹這兩個(gè)屬性
withAnimation(.spring(response: 0.55, dampingFraction: 0.45, blendDuration: 0)) {
  // 兩個(gè)動(dòng)畫同步
}

Animating from state changes

除了對(duì)effect動(dòng)畫, 你也可以對(duì)state變化進(jìn)行動(dòng)畫化. 上面的條形圖例子中, 圖形是立即繪制的, 我們加個(gè)條件:

@State private var showBars = CGFloat(0)

// 改一個(gè)通過geometryProxy來獲取長(zhǎng)度的方法, 即原本計(jì)算的長(zhǎng)度, 再乘這個(gè)showbars(要么是0, 要么是1), 略

// appear的時(shí)候加入這個(gè)條件, 即對(duì)showBars這個(gè)屬性的變化進(jìn)行相應(yīng)的動(dòng)畫
// 到了bar布局容器VStack上:
.onAppear {
  withAnimation(Animation.default.delay(0.5)) {
    self.showBars = CGFloat(1)
  }
}
// 或者手動(dòng)觸發(fā)
Button(action: {
  withAnimation {
    self.showBars = CGFloat(1)
  }
}) {
  Text("Show Bars")
}

Animating changes to the view's appearance

  • The delay() method also gives you a method to make animations appear to connect.
// 把上面在`onAppear`方法里寫動(dòng)畫的代碼改為只設(shè)屬性
.onAppear {
  showBars = true
}
// 然后再對(duì)每條bar animation的時(shí)候延遲一點(diǎn)
// 順便對(duì)index進(jìn)行迭代, 這樣越靠后的bar動(dòng)畫延遲得越久, 造成先后繪制的效果
.animation(
  Animation.easeInOut.delay(index * 0.1)
)

自定義動(dòng)畫

  • 主要就是通過控制動(dòng)畫的進(jìn)度來實(shí)現(xiàn)
  • SwiftUI提供Animatable protocol, 實(shí)現(xiàn)animatableData來描述當(dāng)前進(jìn)度即可
  • 它是一個(gè)服務(wù)VectorArithmetic協(xié)議的類型
  • 但是對(duì)于Path, 它有一個(gè)trim方法能控制path繪制的進(jìn)度, trim方法接受一個(gè)from和一個(gè)to, 任意一個(gè)是state的話, 就能在state變化的時(shí)候觸發(fā)動(dòng)畫
@State private var showPath = false

Path { path in
  path.addLines([
      CGPoint(x: 0, y: 0),
      CGPoint(x: 0, y: 128),
      CGPoint(x: 142, y: 128),
      CGPoint(x: 142, y: 70)
  ])
}
.trim(to: showPath ? 1.0 : 0.0) // 這里
.stroke(Color.blue, lineWidth: 3.0)
.animation(.easeInOut(duration: 3.0), value: UUID())
.onAppear {
  showPath = true
}

靈活運(yùn)用trim的fromto的組合, 可以實(shí)現(xiàn)很多效果, 比如倒放, 消除等, 自己多試試, 對(duì)from進(jìn)行切換會(huì)有很多意想不到的效果哦

  • .trim(from: 0.0, to: showPath ? 1.0 : 0.0) 正向繪制
  • .trim(from: showPath ? 0.0 : 1.0, to: 1.0) 逆向繪制
  • .trim(from: 0.0, to: showPath ? 0.0 : 1.0) 擦除

Animating view transitions

Note: Transitions often render incorrectly in the preview. If you do not see what you expect, try running the app in the simulator or on a device.

  • transition是動(dòng)畫化view hierarchy的變化, 比如一個(gè)view從屏幕上消失, 另一個(gè)view出現(xiàn), 或者一個(gè)view被替換成另一個(gè)view
  • Transitions are specific animations that occur when showing and hiding views.
// 這個(gè)叫State change
Text(
  showTerminal ?
  "Hide Terminal Map" :
  "Show Terminal Map"
)
// 這個(gè)叫View transition
if showTerminal {
  Text("Hide Terminal Map")
} else {
  Text("Show Terminal Map")
}

任意可以選擇性選擇不同view的地方, 都可以加上transition

Group { // 首先用Group包一下
  if showTerminal {
    Text("Hide Terminal Map")
  } else {
    Text("Show Terminal Map")
  }
}
.transition(.slide)
  • opacity: 淡入或淡出(默認(rèn))
  • slide: 從屏幕的一側(cè)滑入或滑出
  • scale: 縮放進(jìn)入或縮放離開, scale入?yún)⑹?code>initial value, anchor是錨點(diǎn), 默認(rèn)是.center
  • move(edge: .bottom): 從屏幕的底部滑入或滑出

但是它不會(huì)自動(dòng)在屬性變化的時(shí)候生效, 需要手動(dòng)觸發(fā)

Button(action: {
  withAnimation { // 需要用withAnimation來包裹
    self.showTerminal.toggle()
  }
}) {
  // 剛剛那個(gè)group的views可以放這里
}

這個(gè)就有點(diǎn)像UIKit的animation方法了, 把屬性的變化包到動(dòng)畫方法里.

Customizing transitions

  • transition可以接受一個(gè)參數(shù), transition(_:animation:)方法, 第一個(gè)參數(shù)是transition的類型, 第二個(gè)參數(shù)是動(dòng)畫的配置
  • transition(_:animation:)方法可以接受一個(gè)Animation對(duì)象, 也可以接受一個(gè)Animation的閉包
// 傳入一個(gè)Animation對(duì)象
.transition(.slide, animation: .easeInOut(duration: 1.0))
// 傳入一個(gè)Animation的閉包
.transition(.slide, animation: Animation.easeInOut(duration: 1.0))

組合

extension AnyTransition {
  static var buttonNameTransition: AnyTransition {
    let insertion = AnyTransition.move(edge: .trailing)
      .combined(with: .opacity)
    let removal = AnyTransition.scale(scale: 0.0)
      .combined(with: .opacity)
    return .asymmetric(insertion: insertion, removal: removal)
  }
}
  1. bombined來支持多個(gè)動(dòng)畫的組合
  2. asymmetric來配置呈現(xiàn)和消失時(shí)不同的動(dòng)畫

Linking view transitions

兩個(gè)視圖, 在同一個(gè)state切換狀態(tài)時(shí), 一個(gè)顯示, 一個(gè)消失, 這兩個(gè)動(dòng)畫沒關(guān)關(guān)聯(lián), 可以用matchedGeometryEffect讓它同步起來

You only must specify the first two parameters.

  • The id uniquely identifies a connection and giving two items the same id links their animations.
  • You pass a Namespace to the in property. The namespace groups related items, and the two together define unique links between views.
    • 定義: @Namespace var namespace
    • 接參: var namespace: Namespace.ID
    • preview里需要手動(dòng)傳下: @Namespace static var namespace

添加這個(gè)方法的仍然是你想要?jiǎng)赢嫷腣iew上, 下面的截圖演示了它的位置并不影響別的modifier:


image.png

ViewBuilder

如果想把這個(gè)視圖改造成組件:

ForEach(flights) { flight in
  FlightCardView(flight: flight)
}

簡(jiǎn)單自定義一個(gè)view就行, 把視圖寫到body方法里, 但是如果FlightCardView這個(gè)也要拿出去自定義怎么辦? 其實(shí)就是把block用ViewBuilder標(biāo)記一下來做入?yún)?

struct GenericTimeline<Content>: View where Content: View {

  let flights: [FlightInformation]
  let content: (FlightInformation) -> Content

  init(
    flights: [FlightInformation],
    @ViewBuilder content: @escaping (FlightInformation) -> Content
  )

  var body: some View {
    ScrollView {
      VStack {
        ForEach(flights) { flight in
          content(flight)
        }
      }
    }
  }
}
  • 以上做了一個(gè)視圖, 接受一個(gè)數(shù)組, 但是沒有幫你生成視圖, 而是讓你傳入應(yīng)該生成怎樣的視圖
  • 這在用同樣的數(shù)據(jù)源產(chǎn)生不同的UI的場(chǎng)景適用
  • <Content>是泛型, 字面文字并不重要, 主要是個(gè)占位, 有多個(gè)泛型就在<>里寫多個(gè)占位符

使用

GenericTimeline(
  flights: mydata
) { flight in
  FlightCardView(flight: flight) // create your view
}

多個(gè)泛型:

struct GenericTimeline<Content, T>: View where Content: View {
  var events: [T]
  let content: (T) -> Content

  init(
    events: [T],
    @ViewBuilder content: @escaping (T) -> Content
  ) {
    self.events = events
    self.content = content
  }

  var body: some View {
    ScrollView {
      VStack {
        ForEach(events.indices) { index in
          content(events[index])
        }
      }
    }
  }
}
  1. ForEach的是eventsindices而不是它本身, 因?yàn)榉盒蚑不能保證Identifiable
  • 所以也可以在where時(shí)約束一下:where Content: View, T: Identifiable
  1. 上面有了兩個(gè)泛型, 再次聲明, 泛型的名字不重要, 自己試下, 把Content全部換成V, 這樣就是V, T兩個(gè)泛型, 一個(gè)是View, 一個(gè)是identifiable.

使用

GenericTimeline(events: flights) { flight in
  FlightCardView(flight: flight) // create your view
}

KeyPaths

KeyPath是Swift的反射機(jī)制, 可以用來獲取對(duì)象的屬性, 比如獲取FlightInformationid屬性:

struct FlightInformation: Identifiable {
  let id = UUID()
  let name: String
  let origin: String
  let destination: String
  let departure: Date
  let arrival: Date
}

KeyPath獲取id屬性:

let idKeyPath = \FlightInformation.id

KeyPath可以用來做ForEachid參數(shù):

GenericTimeline(events: flights, id: \.id) { flight in
  FlightCardView(flight: flight) // create your view
}

如果用的是\.id, 則可以省略.

說回demo, 如果我們UI需要取泛型T的一個(gè)字段來呈現(xiàn), 但又不確定是哪個(gè)字段(一般這種情況, 可能直接設(shè)計(jì)為傳值, 而不是字段), 我們可以把keypath傳進(jìn)來:

let timeProperty: KeyPath<T, Date>

聲明keypath需要兩個(gè)屬性:

  1. T是說明查找keypath的對(duì)象的類型
  2. Date的意思是T的keypath的目標(biāo)類型是Date

所以添加一個(gè)屬性:

struct GenericTimeline<Content, T>: View where Content: View, T: Identifiable, T: Comparable {
  var events: [T]
  let timeProperty: KeyPath<T, Date>
  let content: (T) -> Content

  init(
    events: [T],
    timeProperty: KeyPath<T, Date>,
    @ViewBuilder content: @escaping (T) -> Content
  ) {
    ...
  }
}

// 實(shí)例化時(shí)多了一個(gè)屬性:
timeProperty: \.localTime

傳進(jìn)來是為了用, 直接看看截圖吧


image.png

如果是OC, 可能要簡(jiǎn)單很多, 直接用字符串就行了, swift的更安全.

個(gè)人覺得例子舉得不好, 都泛型了, 還一定要用它的某個(gè)屬性來寫邏輯, 那有何意義? 不過教程只是為了演示用法, 真實(shí)場(chǎng)景還得自己把握.

Integrating with other frameworks

  • To work with UIViews and UIViewControllers in SwiftUI, you must create types that conform to the UIViewRepresentable and UIViewControllerRepresentable protocols. (取決于三方組件是view還是controller)
  • There are two methods in the UIViewControllerRepresentable protocol you will need to implement: makeUIViewController(context:), and updateUIViewController(_:context:).
    • 其實(shí)是三個(gè), 概述一下就是makeView, makeCoordinatorupdateUIView

以連接MapKit為例:

  • makeUIView里需要返回mapkit
  • updateUIView里需要更新mapkit
  • makeCoordinator里需要返回一個(gè)coordinator, 這個(gè)coordinator需要實(shí)現(xiàn)MKMapViewDelegate協(xié)議
struct MapView: UIViewRepresentable {
  func makeUIView(context: Context) -> MKMapView {
    MKMapView(frame: .zero)
  }

  func updateUIView(_ view: MKMapView, context: Context) {
    let coordinate = CLLocationCoordinate2D(latitude: 34.011286, longitude: -116.166868)
    // 定義和添加一系列coordinate, overlay和polyline
    // 以期在coordinator的代理方法里處理成真實(shí)的繪制
  }  

  func makeCoordinator() -> Coordinator {
    MapCoordinator(self)
  }

  class MapCoordinator: NSObject, MKMapViewDelegate {
    var control: MapView // 這里一定要注意, 指回去了

    init(_ control: MapView) {
      self.control = control
    }
  }
  extension MapCoordinator: MKMapViewDelegate {
    func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
      // 處理繪制
    }

  // 繪制circle和連線的代理方法
  func mapView(
      _ mapView: MKMapView,
      rendererFor overlay: MKOverlay
    ) -> MKOverlayRenderer {
      if overlay is MKCircle {
        let renderer = MKCircleRenderer(overlay: overlay)
        renderer.fillColor = UIColor.black
        renderer.strokeColor = UIColor.black
        return renderer
      }

      if overlay is MKGeodesicPolyline {
        let renderer = MKPolylineRenderer(overlay: overlay)
        renderer.strokeColor = UIColor(
          red: 0.0,
          green: 0.0,
          blue: 1.0,
          alpha: 0.3
        )
        renderer.lineWidth = 3.0
        renderer.strokeStart = 0.0
        renderer.strokeEnd = fraction
        return renderer
      }

      return MKOverlayRenderer()
    }
  }
}

MacOS app

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末兢卵,一起剝皮案震驚了整個(gè)濱河市逼纸,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌济蝉,老刑警劉巖杰刽,帶你破解...
    沈念sama閱讀 216,372評(píng)論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異王滤,居然都是意外死亡贺嫂,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門雁乡,熙熙樓的掌柜王于貴愁眉苦臉地迎上來第喳,“玉大人,你說我怎么就攤上這事踱稍∏ィ” “怎么了悠抹?”我有些...
    開封第一講書人閱讀 162,415評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)扩淀。 經(jīng)常有香客問我楔敌,道長(zhǎng),這世上最難降的妖魔是什么驻谆? 我笑而不...
    開封第一講書人閱讀 58,157評(píng)論 1 292
  • 正文 為了忘掉前任卵凑,我火速辦了婚禮,結(jié)果婚禮上胜臊,老公的妹妹穿的比我還像新娘勺卢。我一直安慰自己,他們只是感情好象对,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評(píng)論 6 388
  • 文/花漫 我一把揭開白布黑忱。 她就那樣靜靜地躺著,像睡著了一般勒魔。 火紅的嫁衣襯著肌膚如雪杨何。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,125評(píng)論 1 297
  • 那天沥邻,我揣著相機(jī)與錄音危虱,去河邊找鬼。 笑死唐全,一個(gè)胖子當(dāng)著我的面吹牛埃跷,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播邮利,決...
    沈念sama閱讀 40,028評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼弥雹,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了延届?” 一聲冷哼從身側(cè)響起剪勿,我...
    開封第一講書人閱讀 38,887評(píng)論 0 274
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎方庭,沒想到半個(gè)月后厕吉,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,310評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡械念,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評(píng)論 2 332
  • 正文 我和宋清朗相戀三年头朱,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片龄减。...
    茶點(diǎn)故事閱讀 39,690評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡项钮,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情烁巫,我是刑警寧澤署隘,帶...
    沈念sama閱讀 35,411評(píng)論 5 343
  • 正文 年R本政府宣布,位于F島的核電站亚隙,受9級(jí)特大地震影響磁餐,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜恃鞋,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評(píng)論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望亦歉。 院中可真熱鬧恤浪,春花似錦、人聲如沸肴楷。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)赛蔫。三九已至砂客,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間呵恢,已是汗流浹背鞠值。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評(píng)論 1 268
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留渗钉,地道東北人彤恶。 一個(gè)月前我還...
    沈念sama閱讀 47,693評(píng)論 2 368
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像鳄橘,于是被迫代替她去往敵國(guó)和親声离。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評(píng)論 2 353