Open1

SwiftUI折返し

Shun UematsuShun Uematsu

import SwiftUI

/// 折り返し表示を行ってくれるView
public struct FlexWrapContainer<Content>: View where Content: View {
/// Initializer
/// - Parameters:
/// - data: 表示したい文字列の配列
/// - width: このViewを表示する横幅
/// - horizontalSpacing: 横方向のspacing
/// - verticalSpacing: 縦方向のspacing
/// - content: View
public init(
data: [String],
width: CGFloat,
horizontalSpacing: CGFloat,
verticalSpacing: CGFloat,
content: @escaping (String) -> Content
) {
self.data = data
self.width = width
self.horizontalSpacing = horizontalSpacing
self.verticalSpacing = verticalSpacing
self.content = content
}

private var content: (String) -> Content
private let data: [String]
private let width: CGFloat
private let horizontalSpacing: CGFloat
private let verticalSpacing: CGFloat

public var body: some View {
// X軸方向の基準からの位置
var offsetX: CGFloat = .zero
// Y軸方向の基準からの位置
var offsetY: CGFloat = .zero
// 1行分の高さ
var lineHeight: CGFloat = .zero
// 末尾のアイテムのインデックス
let lastIndex = data.count - 1
ZStack(alignment: .topLeading) {
ForEach(Array(data.enumerated()), id: .offset) { index, item in
content(item)
.padding(.horizontal, horizontalSpacing)
.padding(.vertical, verticalSpacing)
.alignmentGuide(.leading) { context in
let shouldWrap = abs(offsetX - context.width) > width
if shouldWrap {
offsetX = 0
offsetY -= lineHeight
// ここに来た際省略ボタンを表示する
}
if index == 0 {
offsetY += context.height
}
lineHeight = context.height
let result = offsetX
if index == lastIndex {
offsetX = 0
} else {
offsetX -= context.width
}
return result
}
.alignmentGuide(.top) { _ in
let result = offsetY
if index == lastIndex {
offsetY = 0 // なんかこれやらないとうまく動かない
}
return result
}
}
}
}
}

struct FlexWrapContainer_Previews: PreviewProvider {
static var data = [
"!!!",
]
static var previews: some View {
VStack {
FlexWrapContainer(data: data, width: 350, horizontalSpacing: 8, verticalSpacing: 8) {
Text($0)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 30)
.fill(Color.gray.opacity(0.2))
)
}
.frame(width: 350)
}
}
}

import SwiftUI

// 使用可能なスペースが無くなった場合に自動で改行するコンテナ
public struct FlexibleContainer<Data: Collection, Content: View>: View where Data.Element: Hashable {
private let data: Data
private let content: (Data.Element) -> Content
/// 要素の間隔
private let spacing: CGFloat
/// 横幅の表示領域
private let availableWidth: CGFloat

public init(
data: Data,
width: CGFloat,
spacing: CGFloat,
content: @escaping (Data.Element) -> Content
) {
self.data = data
self.availableWidth = width
self.spacing = spacing
self.content = content
}

public var body: some View {
_FlexibleContainer(
availableWidth: availableWidth,
data: data,
spacing: spacing,
content: content
)
}
}

struct _FlexibleContainer<Data: Collection, Content: View>: View where Data.Element: Hashable {
let availableWidth: CGFloat
let data: Data
let spacing: CGFloat
let content: (Data.Element) -> Content
/// 各要素(データ一つ分を表すView)のサイズを格納する辞書
@State private var elementsWidth: [Data.Element: CGFloat] = [:]

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

/// 行ごとに表示するデータを計算する。
/// - Returns: 各行に表示するデータの配列。1次元目が行を表し、2次元目が行に表示するデータを表す。
private func computeRows() -> [[Data.Element]] {
var rows: [[Data.Element]] = [[]]
var currentRow = 0
var remainingWidth = availableWidth

for element in data {
  let width = elementsWidth[element, default: availableWidth]
  let shouldWrap = remainingWidth < (width + spacing)
  if shouldWrap {
    print("shouldWrap: true, currentRow: \(currentRow), title: \(element)")
    currentRow += 1
    rows.append([element])
    remainingWidth = availableWidth
  } else {
    print("shouldWrap: false, currentRow: \(currentRow), title: \(element)")
    rows[currentRow].append(element)
  }
  remainingWidth -= (width + spacing)
}

return rows

}
}

struct FlexibleContainer_Previews: PreviewProvider {
static var data = [
"!!!!",
]

static var previews: some View {
FlexibleContainer(data: data, width: 350, spacing: 16) {
Text($0)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 30)
.fill(Color.gray.opacity(0.2))
)
}
}
}

public extension View {
/// 自身の大きさを取得する
/// - Parameter onChange: CGSizeの変化をコールバックする
/// - Returns: View
func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}

func readRect(onChange: @escaping (CGRect) -> Void) -> some View {
background(
GeometryReader { proxy in
Color.clear
.preference(key: RectPreferenceKey.self, value: proxy.frame(in: .global))
}
)
.onPreferenceChange(RectPreferenceKey.self, perform: onChange)
}
}

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

public struct RectPreferenceKey: PreferenceKey {
public static var defaultValue: CGRect = .zero
public static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
}