SwiftUI:靈活的布局

WWDC20 swiftUI新增LazyVGridLazyHGrid兩種布局方式吕座,我們可以使用它們做網(wǎng)格布局。

autoFlex.png

雖然這些新組件解鎖了非常強大的布局,但SwiftUI還沒有提供UICollectionView那樣的靈活性。
我指的是在同一個容器中有不同大小的多個視圖的可能性,并在沒有更多可用空間時使容器自動換行到下一行仪糖。

在本文中,讓我們探索如何構建我們自己的FlexibleView迫肖,這里是最終結(jié)果的預覽:

flexible.gif

介紹

從上面的預覽應該很清楚我們的目標是什么锅劝,讓我們看看我們的視圖要怎么實現(xiàn)它:

  1. 獲得水平方向的可用空間
  2. 獲取每個元素的size
  3. 一種將每個元素分配到正確位置的方法

獲取Size of View

這個文章將使用SwiftUI:GeometryReader一文中的擴展方法:

extension View {
  func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
    background(
      GeometryReader { geometryProxy in
        Color.clear
          .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
      }
    )
    .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
  }
}

private struct SizePreferenceKey: PreferenceKey {
  static var defaultValue: CGSize = .zero
  static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}

1.獲得水平方向的可用空間

FlexibleView需要的第一個信息是總水平可用空間:
Color做個例子

var body: some View {
  Color.clear
    .frame(height: 1)
    .readSize { size in
      // the horizontal available space is size.width
    }
}

因為第一個組件僅用于獲取布局信息,所以我們使用Color.clear蟆湖。清晰有效地故爵,它是一個不可見的層,不會阻擋視圖的其余部分。

我們也可以設置一個.frame修飾符限制Color的高為1诬垂,確保視圖組件有足夠的高度劲室。

Color不是視圖層次結(jié)構的一部分,我們可以用ZStack隱藏它:

var body: some View {
  ZStack {
    Color.clear
      .frame(height: 1)
      .readSize { size in
        // the horizontal available space is size.width
      }

    // Rest of our implementation
  }
}

最后结窘,讓我們利用回調(diào)從readSize存儲我們的可用水平空間在FlexibleView中:

struct FlexibleView: View {
  @State private var availableWidth: CGFloat = 0

  var body: some View {
    ZStack {
      Color.clear
        .frame(height: 1)
        .readSize { size in
          availableWidth = size.width
        }

      // Rest of our implementation
    }
  }
}

在這一點上很洋,我們有一個視圖,它填滿了所有可用的水平空間隧枫,并且只在高度上取一點喉磁。我們可以進入第二步。

2.獲取每個元素的size

在討論如何獲取每個元素大小之前官脓,讓我們先設置視圖來接受元素协怒。
為了簡單起見,也為了后面更清楚卑笨,我們將要求:

  1. Collection集合中的元素實現(xiàn)Hashable協(xié)議
  2. 一個方法孕暇,給定該集合的一個元素,該方法返回一個視圖View
struct FlexibleView<Data: Collection, Content: View>: View 
  where Data.Element: Hashable {
  let data: Data
  let content: (Data.Element) -> Content

  // ...

  var body: some View {
    // ...
  }
}

讓我們忘記最終的布局赤兴,只關注每個元素的大小:

struct FlexibleView<...>: View where ... {
  let data: Data
  let content: (Data.Element) -> Content
  @State private var elementsSize: [Data.Element: CGSize] = [:]

  // ...

  var body: some View {
    ZStack {
      // ...

      ForEach(data, id: \.self) { element in
        content(element)
          .fixedSize()
          .readSize { size in
            elementsSize[element] = size
          }
      }
    }
  }
}

注意我們是如何在元素視圖上使用.fixedsize修飾符的妖滔,讓它根據(jù)需要占用盡可能多的空間,而不管實際有多少空間可用搀缠。

這樣铛楣,我們就有了每個元素的大小!是時候面對最后一步了近迁。

3.一種將每個元素分配到正確位置的方法

這就是所有FlexibleView需要將元素視圖分布到多行中:

struct FlexibleView<...>: View where ... {
  // ...

  func computeRows() -> [[Data.Element]] {
    var rows: [[Data.Element]] = [[]]
    var currentRow = 0
    var remainingWidth = availableWidth

    for element in data {
      let elementSize = elementSizes[element, default: CGSize(width: availableWidth, height: 1)]

      if remainingWidth - elementSize.width >= 0 {
        rows[currentRow].append(element)
      } else {
        // start a new row
        currentRow = currentRow + 1
        rows.append([element])
        remainingWidth = availableWidth
      }

      remainingWidth = remainingWidth - elementSize.width
    }

    return rows
  }
}

computeRows將所有元素分布在多行中艺普,同時保持元素的順序,并確保每一行的寬度不超過之前獲得的可用寬度鉴竭。

換句話說歧譬,該函數(shù)返回一個行數(shù)組,其中每行包含該行的元素數(shù)組搏存。

然后瑰步,我們可以將這個新函數(shù)與HStacksVstack結(jié)合起來,得到最終的布局:

struct FlexibleView<...>: View where ... {
  // ...

  var body: some View {
    ZStack {
      // ...

      VStack {
        ForEach(computeRows(), id: \.self) { rowElements in
          HStack {
            ForEach(rowElements, id: \.self) { element in
              content(element)
                .fixedSize()
                .readSize { size in
                  elementsSize[element] = size
                }
            }
          }
        }
      }
    }
  }

  // ...
}

在這一點上璧眠,FlexibleView將只采取這個VStack的高度

有了這個缩焦,我們就結(jié)束了!最終的項目還處理了元素之間的間距和不同的排列:一旦理解了上面的基本原理,添加這些特性就變得很簡單了责静。

完整代碼:

//ContentView.swift
import SwiftUI

class ContentViewModel: ObservableObject {

  @Published var originalItems = [
    "Here’s", "to", "the", "crazy", "ones", "the", "misfits", "the", "rebels", "the", "troublemakers", "the", "round", "pegs", "in", "the", "square", "holes", "the", "ones", "who", "see", "things", "differently", "they’re", "not", "fond", "of", "rules", "You", "can", "quote", "them", "disagree", "with", "them", "glorify", "or", "vilify", "them", "but", "the", "only", "thing", "you", "can’t", "do", "is", "ignore", "them", "because", "they", "change", "things", "they", "push", "the", "human", "race", "forward", "and", "while", "some", "may", "see", "them", "as", "the", "crazy", "ones", "we", "see", "genius", "because", "the", "ones", "who", "are", "crazy", "enough", "to", "think", "that", "they", "can", "change", "the", "world", "are", "the", "ones", "who", "do"
  ]

  @Published var spacing: CGFloat = 8
  @Published var padding: CGFloat = 8
  @Published var wordCount: Int = 75
  @Published var alignmentIndex = 0

  var words: [String] {
    Array(originalItems.prefix(wordCount))
  }

  let alignments: [HorizontalAlignment] = [.leading, .center, .trailing]

  var alignment: HorizontalAlignment {
    alignments[alignmentIndex]
  }
}

struct ContentView: View {
  @StateObject var model = ContentViewModel()

  var body: some View {
    ScrollView {
      FlexibleView(
        data: model.words,
        spacing: model.spacing,
        alignment: model.alignment
      ) { item in
        Text(verbatim: item)
          .padding(8)
          .background(
            RoundedRectangle(cornerRadius: 8)
              .fill(Color.gray.opacity(0.2))
           )
      }
      .padding(.horizontal, model.padding)
    }
    .overlay(Settings(model: model), alignment: .bottom)
  }
}

struct Settings: View {
  @ObservedObject var model: ContentViewModel
  let alignmentName: [String] = ["leading", "center", "trailing"]

  var body: some View {
    VStack {
      Stepper(value: $model.wordCount, in: 0...model.originalItems.count) {
        Text("\(model.wordCount) words")
      }

      HStack {
        Text("Padding")
        Slider(value: $model.padding, in: 0...60) { Text("") }
      }

      HStack {
        Text("Spacing")
        Slider(value: $model.spacing, in: 0...40) { Text("") }
      }

      HStack {
        Text("Alignment")
        Picker("Choose alignment", selection: $model.alignmentIndex) {
          ForEach(0..<model.alignments.count) {
            Text(alignmentName[$0])
          }
        }
        .pickerStyle(SegmentedPickerStyle())
      }

      Button {
        model.originalItems.shuffle()
      } label: {
        Text("Shuffle")
      }
    }
    .padding()
    .background(Color(UIColor.systemBackground))
    .clipShape(RoundedRectangle(cornerRadius: 20))
    .overlay(
         RoundedRectangle(cornerRadius: 20)
             .stroke(Color.primary, lineWidth: 4)
     )
    .padding()
  }
}

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

//FlexibleView.swift
struct FlexibleView<Data: Collection, Content: View>: View where Data.Element: Hashable {
  let data: Data
  let spacing: CGFloat
  let alignment: HorizontalAlignment
  let content: (Data.Element) -> Content
  @State private var availableWidth: CGFloat = 0

  var body: some View {
    ZStack(alignment: Alignment(horizontal: alignment, vertical: .center)) {
      Color.clear
        .frame(height: 1)
        .readSize { size in
          availableWidth = size.width
        }

      _FlexibleView(
        availableWidth: availableWidth,
        data: data,
        spacing: spacing,
        alignment: alignment,
        content: content
      )
    }
  }
}

//_FlexibleView.swift
struct _FlexibleView<Data: Collection, Content: View>: View where Data.Element: Hashable {
  let availableWidth: CGFloat
  let data: Data
  let spacing: CGFloat
  let alignment: HorizontalAlignment
  let content: (Data.Element) -> Content
  @State var elementsSize: [Data.Element: CGSize] = [:]

  var body : some View {
    VStack(alignment: alignment, spacing: spacing) {
      ForEach(computeRows(), id: \.self) { rowElements in
        HStack(spacing: spacing) {
          ForEach(rowElements, id: \.self) { element in
            content(element)
              .fixedSize()
              .readSize { size in
                elementsSize[element] = size
              }
          }
        }
      }
    }
  }

  func computeRows() -> [[Data.Element]] {
    var rows: [[Data.Element]] = [[]]
    var currentRow = 0
    var remainingWidth = availableWidth

    for element in data {
      let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)]

      if remainingWidth - (elementSize.width + spacing) >= 0 {
        rows[currentRow].append(element)
      } else {
        currentRow = currentRow + 1
        rows.append([element])
        remainingWidth = availableWidth
      }

      remainingWidth = remainingWidth - (elementSize.width + spacing)
    }

    return rows
  }
}

//SizeReader.swift
extension View {
  func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
    background(
      GeometryReader { geometryProxy in
        Color.clear
          .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
      }
    )
    .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
  }
}

private struct SizePreferenceKey: PreferenceKey {
  static var defaultValue: CGSize = .zero
  static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末袁滥,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子灾螃,更是在濱河造成了極大的恐慌题翻,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,123評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件腰鬼,死亡現(xiàn)場離奇詭異嵌赠,居然都是意外死亡塑荒,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,031評論 2 384
  • 文/潘曉璐 我一進店門姜挺,熙熙樓的掌柜王于貴愁眉苦臉地迎上來齿税,“玉大人,你說我怎么就攤上這事炊豪≠司剑” “怎么了?”我有些...
    開封第一講書人閱讀 156,723評論 0 345
  • 文/不壞的土叔 我叫張陵溜在,是天一觀的道長陌知。 經(jīng)常有香客問我,道長掖肋,這世上最難降的妖魔是什么仆葡? 我笑而不...
    開封第一講書人閱讀 56,357評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮志笼,結(jié)果婚禮上沿盅,老公的妹妹穿的比我還像新娘。我一直安慰自己纫溃,他們只是感情好腰涧,可當我...
    茶點故事閱讀 65,412評論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著紊浩,像睡著了一般窖铡。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上坊谁,一...
    開封第一講書人閱讀 49,760評論 1 289
  • 那天费彼,我揣著相機與錄音,去河邊找鬼口芍。 笑死箍铲,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的鬓椭。 我是一名探鬼主播颠猴,決...
    沈念sama閱讀 38,904評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼小染!你這毒婦竟也來了翘瓮?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,672評論 0 266
  • 序言:老撾萬榮一對情侶失蹤氧映,失蹤者是張志新(化名)和其女友劉穎春畔,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,118評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡律姨,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,456評論 2 325
  • 正文 我和宋清朗相戀三年振峻,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片择份。...
    茶點故事閱讀 38,599評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡扣孟,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出荣赶,到底是詐尸還是另有隱情凤价,我是刑警寧澤,帶...
    沈念sama閱讀 34,264評論 4 328
  • 正文 年R本政府宣布拔创,位于F島的核電站利诺,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏剩燥。R本人自食惡果不足惜慢逾,卻給世界環(huán)境...
    茶點故事閱讀 39,857評論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望灭红。 院中可真熱鬧侣滩,春花似錦、人聲如沸变擒。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,731評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽娇斑。三九已至策添,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間悠菜,已是汗流浹背舰攒。 一陣腳步聲響...
    開封第一講書人閱讀 31,956評論 1 264
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留悔醋,地道東北人。 一個月前我還...
    沈念sama閱讀 46,286評論 2 360
  • 正文 我出身青樓兽叮,卻偏偏與公主長得像芬骄,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子鹦聪,可洞房花燭夜當晚...
    茶點故事閱讀 43,465評論 2 348

推薦閱讀更多精彩內(nèi)容