10

I'm trying to make a fully custom list of expandable sections that have projects inside them in SwiftUI. This is how I want it to look in the end:

enter image description here

I think I have the SwiftUI code set up right, but I'm having trouble finding view modifiers to accomplish what I want.

Here is my code with most style modifiers removed for brevity:

List {
  ForEach(sections, id: \.self) { section in
  
    DisclosureGroup(isExpanded: $expand) {
      ForEach(section.projectArray, id: \.self) { project in
        //--- Projects ---
        HStack{
          Image("project")
          Text(project.wrappedName)
          Spacer()
        }
        .padding(EdgeInsets(top: 0, leading: 0, bottom:0, trailing: 0))
      } 
    } label: {
      //--- Sections ---
      HStack{
        Text(section.wrappedName)
        Spacer()
        //Custom Toggle Arrow
        Button(action: {
          //Toggle logic
        }){
          if expand{
            Image("section-open")
          }else{
            Image("section-closed")
          }
        }
      }
      .padding(0)
    }
  } 
}.listStyle(PlainListStyle())

I can't find anything to change DisclosureGroup that adds a few default styles I don't want:

A - A default expand/collapse arrow

B - When expanded, the DisclosureGroup's label grows horizontally

C - Default padding on the child elements

enter image description here

I checked the docs and don't see a way to remove these default styles. Any ideas how I can pull off this design?

Clifton Labrum
  • 13,053
  • 9
  • 65
  • 128

3 Answers3

3

Try to set listRowInsets's leading minus 8 for your DisclosureGroup's content

List {
    ForEach(sections) { section in
        DisclosureGroup {
            ForEach(section.items) {

            }.listRowInsets(.init(top: 0, leading: -8, bottom: 0, trailing: 0))
        } label: { 

        }
    }
}
Quang Hà
  • 4,613
  • 3
  • 24
  • 40
2

For iOS 16+ use DisclosureGroupStyle :

import SwiftUI

struct CustomDisclosureGroupStyle<Label: View>: DisclosureGroupStyle {
    let button: Label
    func makeBody(configuration: Configuration) -> some View {
        VStack(alignment: .center, spacing: 0) {
            HStack(alignment: .top, spacing: 0) {
                configuration.label
                Spacer()
                button
            }
            .contentShape(Rectangle())
            .onTapGesture {
                withAnimation {
                    configuration.isExpanded.toggle()
                }
            }
            if configuration.isExpanded {
                configuration.content
                    .disclosureGroupStyle(self)
            }
        }
    }
}

Here's a replacement for DisclosureGroup that allows customisation of the toggle image, size & color dynamically according to isExpanded:

/// Toggle Expand-Collapse section with:
/// * Label - header ending with Image button
/// * Image button - customisable expand-collapse toggle control
/// * Content - whatever you want to contain (expand to show, collapse to hide)
///
/// NOTE:
/// * Works with both 'ForEach {' and 'List { ForEach {' as Content.
/// * If a List is used, expands downwards to fill remainder of screen.
/// * If no List is used, expands just large enough to fit content height.
struct ExpandCollapse<Content: View, Label: View>: View {
    
    typealias makeContent = () -> Content
    typealias makeLabel = () -> Label
    
    typealias dynamicImageColor = (Bool) -> Color
    typealias dynamicRotation = (Bool) -> Double
    typealias dynamicSystemName = (Bool) -> String
    
    @Binding var isExpanded: Bool
    
    @ViewBuilder var content: makeContent
    @ViewBuilder var label: makeLabel
    
    let imageSize: CGFloat
    let imageRotation: dynamicRotation
    let imageColor: dynamicImageColor
    let imageName: dynamicSystemName
    
    init(
        isExpanded: Binding<Bool>,
        content: @escaping makeContent,
        label: @escaping makeLabel,
        imageSize: CGFloat = 18,
        imageColor: @escaping dynamicImageColor = { isExpanded in isExpanded ? Color.blue: Color.red },
        imageRotation: @escaping dynamicRotation = { _ in 0 },
        imageName: @escaping dynamicSystemName = { isExpanded in isExpanded ? "chevron.up" : "chevron.down" }
    ) {
        _isExpanded = isExpanded
        self.content = content
        self.label = label
        
        self.imageSize = imageSize
        self.imageColor = imageColor
        self.imageRotation = imageRotation
        self.imageName = imageName
    }
    
    var expandCollapseButton: some View {
        Image(systemName: imageName(isExpanded))
            .resizable()
            .scaledToFit()
            .frame(width: imageSize, height: imageSize, alignment: .center)
            .foregroundColor(imageColor(isExpanded))
            .rotationEffect(.degrees(imageRotation(isExpanded)))
            .pad(left: 5, right: 10)
    }
    
    var body: some View {
        DisclosureGroup(isExpanded: $isExpanded, content: content, label: label)
        .disclosureGroupStyle(CustomDisclosureGroupStyle(button: expandCollapseButton))
    }
}

The definition for .pad is:

import SwiftUI

@inlinable func insets(top: CGFloat = 0,
                       bot bottom: CGFloat = 0,
                       left leading: CGFloat = 0,
                       right trailing: CGFloat = 0) -> EdgeInsets {
    EdgeInsets(top: top, leading: leading, bottom: bottom, trailing: trailing)
}

extension View {
    
    @inlinable func pad(top: CGFloat = 0,
                        bot: CGFloat = 0,
                        left: CGFloat = 0,
                        right: CGFloat = 0) -> some View {
        self.padding(insets(top: top, bot: bot, left: left, right: right))
    }
    
    @inlinable func zeropad() -> some View { pad() }
}

extension EdgeInsets {
    static let zero = insets()
}

Example usage:

struct SimpleExampleView<T: MyData>: View {

    @State isExpanded: Bool
    @Binding items: [T]

    func buildRow(item: T) -> some view { Text(item.title) }

    var body: some View {
        ExpandCollapse(isExpanded: $isExpanded) {
            //List { // uncomment this & others to use List variant
                ForEach($items) { index, item in
                    self.buildRow(item: item)
                    //.listRowBackground(Color.clear)
                }
            //}.listStyle(.plain)
        } label: {
            Text("\(items.count) Items")
        }
    }

With the example run as shown above, the ExpandCollapse will expand just enough to fit its content height.

Alternatively, you can uncomment the indicated lines so a List is used. Then the ExpandCollapse will expand downwards to fill the remainder of the screen. This produces as large an area as possible for list interaction such as drag 'n' drop.

kwiknik
  • 570
  • 3
  • 7
1

This is a hacky way which won't work inside List but it will work inside LazyVStack and other stacks.

import SwiftUI

struct ContentView: View {
    @State var isExpanded: Bool = false
    var body: some View {
        ScrollView {
            LazyVStack {
                DisclosureGroup("Disclosure", isExpanded: $isExpanded) {
                    Text("Hello, world!")
                        .padding()
                }
                .buttonStyle(DisclosureStyle(isExpanded: $isExpanded))

            }
            .padding(.horizontal)
        }
    }
}

struct DisclosureStyle: ButtonStyle {
    @Binding var isExpanded: Bool
    func makeBody(configuration: Configuration) -> some View {
        HStack {
            Image(systemName: isExpanded ? "chevron.down.circle" : "chevron.right.circle")
                .if(configuration.isPressed) { image in
                    image.symbolVariant(.fill)
                }
                .foregroundStyle(isExpanded ? .green : .accentColor)
            Spacer()
            let font = isExpanded ? Font.headline.monospaced() : .headline
            configuration.label
                .font(font).id(font)
                .overlay(alignment: .topTrailing) {
                    Rectangle().fill(.bar).frame(maxWidth: 30)
            }
            .foregroundStyle(isExpanded ? .green : .accentColor)
        }
        .background(.bar)
    }
}

extension View {
    @ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
        if condition {
            transform(self)
        } else {
            self
        }
    }
}

The result looks like this: Custom DisclosureGroup

Paul B
  • 3,989
  • 33
  • 46