🦋
SwiftUI: ListのUXを再現しつつ幅は中身のサイズに合わせる
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
で定義 -
ScrollView
とVStack
、DrogDelegate
でD&Dを独自実装 -
Content
にonDrag()
をつける -
Content
とDivider
の双方にonDrop()
をつける - ソート時のAccent Colorの補助UIはD&D中のマウスの座標を見て
overlay()
で再現 - 当たり判定に高さが必要なため、
GeometryReader
でContent
のサイズを計測
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