🦋

SwiftUI: ListのUXを再現しつつ幅は中身のサイズに合わせる

2024/12/31に公開

SwiftUIのListは手軽にリッチなUXを提供できるのですが、中身にfixedSize()をつけてもList全体の幅が中身にあったサイズにならないという不便さがあります。あくまで固定の幅ではなく中身の幅を尊重したい場合にListは基本無力です。しかし、onMove()によるソートは強力なためぜひ使いたい。

Listの実装例
import SwiftUI

struct Item: Identifiable, Equatable {
    var name: String
    var id: UUID = .init()
}

struct ContentView: View {
    @State var items: [Item] = [
        .init(name: "Apple"),
        .init(name: "Banana"),
        .init(name: "Cherry")
    ]

    var body: some View {
        List {
            ForEach(items) { item in
                Text(item.name).fixedSize()
            }
            .onMove { indexSet, offset in
                items.move(fromOffsets: indexSet, toOffset: offset)
            }
        }
    }
}

たったこれだけの実装でリッチなソートUXを提供できます。

しかし、いろいろ試行錯誤してみても、根本的にListは中身のサイズに依らず親Viewのサイズに合わせて領域が確保されるようです。macOSの場合はウインドウのサイズが自由に変更できるため、場合によっては中身の大きさに合わせてListを表示したいこともあります。なので、ListのようなUXを提供しつつ幅を中身のサイズに合わせられる独自のViewを実装します。

実装の概要は以下の通り

  • 独自Listで取り扱うデータの型をprotocolで定義
  • ScrollViewVStackDrogDelegateでD&Dを独自実装
  • ContentonDrag()をつける
  • ContentDividerの双方にonDrop()をつける
  • ソート時のAccent Colorの補助UIはD&D中のマウスの座標を見てoverlay()で再現
  • 当たり判定に高さが必要なため、GeometryReaderContentのサイズを計測
protocol FixedListItem: Equatable, Identifiable {
    var id: UUID { get set }
}

struct FixedList<Content: View, Item: FixedListItem>: View {
    @Binding var items: [Item]
    var eachContent: (Item) -> Content
    @State private var draggingItem: Item?
    @State private var hoveringIndex: Int?

    var body: some View {
        ScrollView(.vertical) {
            VStack(alignment: .leading, spacing: 0) {
                ForEach(0 ... items.count, id: \.self) { index in
                    Divider()
                        .modifier(FixedListDividerViewModifier(
                            index: index,
                            items: $items,
                            draggingItem: $draggingItem,
                            hoveringIndex: $hoveringIndex
                        ))
                    if index < items.count {
                        eachContent(items[index])
                            .modifier(FixedListItemViewModifier(
                                index: index,
                                items: $items,
                                draggingItem: $draggingItem,
                                hoveringIndex: $hoveringIndex
                            ))
                    }
                }
            }
            .fixedSize()
        }
    }
}

struct FixedListItemViewModifier<Item: FixedListItem>: ViewModifier {
    var index: Int
    @Binding var items: [Item]
    @Binding var draggingItem: Item?
    @Binding var hoveringIndex: Int?
    @State private var size: CGSize = .zero

    func body(content: Content) -> some View {
        content
            .background(SizeOfView(size: $size))
            .onDrag {
                draggingItem = items[index]
                return NSItemProvider(
                    contentsOf: URL(string: items[index].id.uuidString)!,
                    contentType: .item
                )
            }
            .onDrop(
                of: [.item],
                delegate: FixedListItemDelegate(
                    index: index,
                    height: size.height,
                    items: $items,
                    draggingItem: $draggingItem,
                    hoveringIndex: $hoveringIndex
                )
            )
            .padding(.horizontal)
    }
}

struct SizeOfView: View {
    @Binding var size: CGSize

    var body: some View {
        GeometryReader { proxy in
            Color.clear.onAppear { size = proxy.size }
        }
    }
}

struct FixedListItemDelegate<Item: FixedListItem>: DropDelegate {
    var index: Int
    var height: CGFloat
    @Binding var items: [Item]
    @Binding var draggingItem: Item?
    @Binding var hoveringIndex: Int?

    func performDrop(info: DropInfo) -> Bool {
        if let draggingItem, info.hasItemsConforming(to: [.item]),
           let fromIndex = items.firstIndex(of: draggingItem),
           fromIndex != index {
            let offset = index + (fromIndex < index ? 1 : 0)
            items.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: offset)
            self.draggingItem = nil
            return true
        } else {
            self.draggingItem = nil
            return false
        }
    }

    func dropEntered(info: DropInfo) {
        hoveringIndex = if info.location.y < (0.5 * height) {
            index
        } else {
            index + 1
        }
    }

    func dropUpdated(info: DropInfo) -> DropProposal? {
        hoveringIndex = if info.location.y < (0.5 * height) {
            index
        } else {
            index + 1
        }
        return DropProposal(operation: .move)
    }

    func dropExited(info: DropInfo) {
        hoveringIndex = nil
    }
}

struct FixedListDividerViewModifier<Item: FixedListItem>: ViewModifier {
    var index: Int
    @Binding var items: [Item]
    @Binding var draggingItem: Item?
    @Binding var hoveringIndex: Int?

    func body(content: Content) -> some View {
        content
            .opacity((index == 0 || index == items.count) ? 0 : 1)
            .frame(height: 7)
            .onDrop(
                of: [.item],
                delegate: FixedListDividerDelegate(
                    index: index,
                    items: $items,
                    draggingItem: $draggingItem,
                    hoveringIndex: $hoveringIndex
                )
            )
            .padding(.leading)
            .overlay {
                if index == hoveringIndex {
                    Canvas { contex, cSize in
                        contex.stroke(
                            Path(ellipseIn: CGRect(x: 9, y: 1, width: 5, height: 5)),
                            with: .color(.accentColor.opacity(0.8)),
                            lineWidth: 2
                        )
                        var path = Path()
                        path.move(to: CGPoint(x: 15, y: 0.5 * cSize.height))
                        path.addLine(to: CGPoint(x: cSize.width, y: 0.5 * cSize.height))
                        contex.stroke(path, with: .color(.accentColor.opacity(0.8)), lineWidth: 2)
                    }
                }
            }
            .padding(.trailing)
    }
}

struct FixedListDividerDelegate<Item: FixedListItem>: DropDelegate {
    var index: Int
    @Binding var items: [Item]
    @Binding var draggingItem: Item?
    @Binding var hoveringIndex: Int?

    func performDrop(info: DropInfo) -> Bool {
        if let draggingItem, info.hasItemsConforming(to: [.item]),
           let fromIndex = items.firstIndex(of: draggingItem),
           fromIndex != index && fromIndex + 1 != index {
            let offset = index
            items.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: offset)
            self.draggingItem = nil
            return true
        } else {
            self.draggingItem = nil
            return false
        }
    }

    func dropEntered(info: DropInfo) {
        hoveringIndex = index
    }

    func dropUpdated(info: DropInfo) -> DropProposal? {
        DropProposal(operation: .move)
    }

    func dropExited(info: DropInfo) {
        hoveringIndex = nil
    }
}

これで、以下のように独自Listが使えます。

struct Item: FixedListItem {
    var name: String
    var id: UUID = .init()
}

struct ContentView: View {
    @State var items: [Item] = [
        .init(name: "Apple"),
        .init(name: "Banana"),
        .init(name: "Cherry")
    ]

    var body: some View {
        FixedList(items: $items) { item in
            Text(item.name).fixedSize()
        }
    }
}

Discussion