Reading time: 2 min

A Flow Layout is a container that orders its views sequentially, breaking into a new "line" according to the available width of the screen. You can compare it to a left-aligned block of text, where every word is a View. A common use for this layout is to create a tag cloud. The end result looks something like this:

This component is available as a Swift Package in this repo.

There are a few points to go over before we jump to the code:

  • The layout algorithm behaves differently if your FlowLayout is nested in a VStack or a scrollable parent, such as ScrollView or a List. Therefore, there's the Mode enum and mode property.
  • The binding property is there to allow you to have a way to refresh the content of the entire layout via a state change.
  • The layout computes the positioning of its subviews on the fly, using the alignmentGuide method.
  • Total height of the layout is updated once its re-drawn, using a GeometryReader.

So, here's the code:

struct FlowLayout<B, T: Hashable, V: View>: View {
  let mode: Mode
  @Binding var binding: B
  let items: [T]
  let viewMapping: (T) -> V

  @State private var totalHeight: CGFloat

  init(mode: Mode, binding: Binding<B>, items: [T], viewMapping: @escaping (T) -> V) {
    self.mode = mode
    _binding = binding
    self.items = items
    self.viewMapping = viewMapping
    _totalHeight = State(initialValue: (mode == .scrollable) ? .zero : .infinity)
  }

  var body: some View {
    let stack = VStack {
       GeometryReader { geometry in
         self.content(in: geometry)
       }
    }
    return Group {
      if mode == .scrollable {
        stack.frame(height: totalHeight)
      } else {
        stack.frame(maxHeight: totalHeight)
      }
    }
  }

  private func content(in g: GeometryProxy) -> some View {
    var width = CGFloat.zero
    var height = CGFloat.zero
    return ZStack(alignment: .topLeading) {
      ForEach(self.items, id: \.self) { item in
        self.viewMapping(item)
          .padding([.horizontal, .vertical], 4)
          .alignmentGuide(.leading, computeValue: { d in
            if (abs(width - d.width) > g.size.width) {
              width = 0
              height -= d.height
            }
            let result = width
            if item == self.items.last {
              width = 0
            } else {
              width -= d.width
            }
            return result
          })
          .alignmentGuide(.top, computeValue: { d in
            let result = height
            if item == self.items.last {
              height = 0
            }
            return result
          })
        }
      }
      .background(viewHeightReader($totalHeight))
  }

  private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
    return GeometryReader { geo -> Color in
      DispatchQueue.main.async {
        binding.wrappedValue = geo.frame(in: .local).size.height
      }
      return .clear
    }
  }

  enum Mode {
    case scrollable, vstack
  }
}

struct FlowLayout_Previews: PreviewProvider {
  static var previews: some View {
    FlowLayoutView(mode: .scrollable,
                               binding: .constant(5),
                               items: ["Some long item here", "And then some longer one",
                                          "Short", "Items", "Here", "And", "A", "Few", "More", 
                                          "And then a very very very long one"]) {
      Text($0)
        .font(.system(size: 12))
        .foregroundColor(.black)
        .padding()
        .background(RoundedRectangle(cornerRadius: 4)
                               .border(Color.gray)
                               .foregroundColor(Color.gray))
    }.padding()
  }
}

Next Post Previous Post