Skip to content

Commit

Permalink
Merge pull request #36 from dkk/feature/performance-optimization
Browse files Browse the repository at this point in the history
Performance optimization
Fixes #34 & #4
  • Loading branch information
dkk committed Dec 3, 2022
2 parents 321dccb + ccade82 commit 9f5f586
Show file tree
Hide file tree
Showing 9 changed files with 192 additions and 156 deletions.
45 changes: 45 additions & 0 deletions Sources/WrappingHStack/ContentManager.swift
@@ -0,0 +1,45 @@
import SwiftUI

/// This class manages content and the calculation of their widths (reusing it).
/// It should be reused whenever possible.
class ContentManager {
enum ViewType {
case any(AnyView)
case newLine

init<V: View>(rawView: V) {
switch rawView {
case is NewLine: self = .newLine
default: self = .any(AnyView(rawView))
}
}
}

let items: [ViewType]
lazy var widths: [Double] = {
items.map {
if case let .any(anyView) = $0 {
return Self.getWidth(of: anyView)
} else {
return 0
}
}
}()

init(items: [ViewType]) {
self.items = items
}

@inline(__always) private static func getWidth(of anyView: AnyView) -> Double {
#if os(iOS)
let hostingController = UIHostingController(rootView: HStack { anyView })
#else
let hostingController = NSHostingController(rootView: HStack { anyView })
#endif
return hostingController.sizeThatFits(in: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)).width
}

func isVisible(viewIndex: Int) -> Bool {
widths[viewIndex] > 0
}
}
85 changes: 17 additions & 68 deletions Sources/WrappingHStack/InternalWrappingHStack.swift
@@ -1,108 +1,57 @@
import SwiftUI

// based on https://swiftui.diegolavalle.com/posts/linewrapping-stacks/
/// This View draws the WrappingHStack content taking into account the passed width, alignment and spacings.
/// Note that the passed LineManager and ContentManager should be reused whenever possible.
struct InternalWrappingHStack: View {
let width: CGFloat
let alignment: HorizontalAlignment
let spacing: WrappingHStack.Spacing
let content: [WrappingHStack.ViewType]
let firstItemOfEachLine: [Int]
let lineSpacing: CGFloat
let lineManager: LineManager
let contentManager: ContentManager

init(width: CGFloat, alignment: HorizontalAlignment, spacing: WrappingHStack.Spacing, lineSpacing: CGFloat, content: [WrappingHStack.ViewType]) {
self.width = width
init(width: CGFloat, alignment: HorizontalAlignment, spacing: WrappingHStack.Spacing, lineSpacing: CGFloat, lineManager: LineManager, contentManager: ContentManager) {
self.alignment = alignment
self.spacing = spacing
self.lineSpacing = lineSpacing
self.content = content
self.contentManager = contentManager
self.lineManager = lineManager

firstItemOfEachLine = content
.enumerated()
.reduce((firstItemOfEachLine: [], currentLineWidth: width)) { (result, contentIterator) -> (firstItemOfEachLine: [Int], currentLineWidth: CGFloat) in
var (firstItemOfEachLine, currentLineWidth) = result

switch contentIterator.element {
case .newLine:
return (firstItemOfEachLine + [contentIterator.offset], width)
case .any(let anyView) where Self.isVisible(view: anyView):
let itemWidth = Self.getWidth(of: anyView)
if result.currentLineWidth + itemWidth + spacing.minSpacing > width {
currentLineWidth = itemWidth
firstItemOfEachLine.append(contentIterator.offset)
} else {
currentLineWidth += itemWidth + spacing.minSpacing
}
return (firstItemOfEachLine, currentLineWidth)
default:
return result
}
}.0
}

static func getWidth(of anyView: AnyView) -> Double {
#if os(iOS)
let hostingController = UIHostingController(rootView: HStack { anyView })
#else
let hostingController = NSHostingController(rootView: HStack { anyView })
#endif
return hostingController.sizeThatFits(in: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)).width
}

var totalLines: Int {
firstItemOfEachLine.count
}

func startOf(line i: Int) -> Int {
firstItemOfEachLine[i]
}

func endOf(line i: Int) -> Int {
i == totalLines - 1 ? content.count - 1 : firstItemOfEachLine[i + 1] - 1
}

func hasExactlyOneElement(line i: Int) -> Bool {
startOf(line: i) == endOf(line: i)
if !lineManager.isSetUp {
lineManager.setup(contentManager: contentManager, width: width, spacing: spacing)
}
}

func shouldHaveSideSpacers(line i: Int) -> Bool {
if case .constant = spacing {
return true
}
if case .dynamic = spacing, hasExactlyOneElement(line: i) {
if case .dynamic = spacing, lineManager.hasExactlyOneElement(line: i) {
return true
}
return false
}

@inline(__always) static func isVisible(view: AnyView) -> Bool {
#if os(iOS)
return UIHostingController(rootView: view).sizeThatFits(in: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)).width > 0
#else
return NSHostingController(rootView: view).sizeThatFits(in: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)).width > 0
#endif
}

var body: some View {
VStack(alignment: alignment, spacing: lineSpacing) {
ForEach(0 ..< totalLines, id: \.self) { lineIndex in
ForEach(0 ..< lineManager.totalLines, id: \.self) { lineIndex in
HStack(spacing: 0) {
if alignment == .center || alignment == .trailing, shouldHaveSideSpacers(line: lineIndex) {
Spacer(minLength: 0)
}

ForEach(startOf(line: lineIndex) ... endOf(line: lineIndex), id: \.self) {
ForEach(lineManager.startOf(line: lineIndex) ... lineManager.endOf(line: lineIndex), id: \.self) {
if case .dynamicIncludingBorders = spacing,
startOf(line: lineIndex) == $0
lineManager.startOf(line: lineIndex) == $0
{
Spacer(minLength: spacing.minSpacing)
}

if case .any(let anyView) = content[$0], Self.isVisible(view: anyView) {
if case .any(let anyView) = contentManager.items[$0], contentManager.isVisible(viewIndex: $0) {
anyView
}

if endOf(line: lineIndex) != $0 {
if case .any(let anyView) = content[$0], !Self.isVisible(view: anyView) { } else {
if lineManager.endOf(line: lineIndex) != $0 {
if case .any = contentManager.items[$0], !contentManager.isVisible(viewIndex: $0) { } else {
if case .constant(let exactSpacing) = spacing {
Spacer(minLength: 0)
.frame(width: exactSpacing)
Expand Down
59 changes: 59 additions & 0 deletions Sources/WrappingHStack/LineManager.swift
@@ -0,0 +1,59 @@
import Foundation

/// This class is in charge of calculating which items fit on which lines.
/// It should be reused whenever possible.
class LineManager {
private var contentManager: ContentManager!
private var spacing: WrappingHStack.Spacing!
private var width: CGFloat!

lazy var firstItemOfEachLine: [Int] = {
var firstOfEach = [Int]()
var currentWidth: CGFloat = width
for (index, element) in contentManager.items.enumerated() {
switch element {
case .newLine:
firstOfEach += [index]
currentWidth = width
case .any where contentManager.isVisible(viewIndex: index):
let itemWidth = contentManager.widths[index]
if currentWidth + itemWidth + spacing.minSpacing > width {
currentWidth = itemWidth
firstOfEach.append(index)
} else {
currentWidth += itemWidth + spacing.minSpacing
}
default:
break
}
}

return firstOfEach
}()

var isSetUp: Bool {
width != nil
}

func setup(contentManager: ContentManager, width: CGFloat, spacing: WrappingHStack.Spacing) {
self.contentManager = contentManager
self.width = width
self.spacing = spacing
}

var totalLines: Int {
firstItemOfEachLine.count
}

func startOf(line i: Int) -> Int {
firstItemOfEachLine[i]
}

func endOf(line i: Int) -> Int {
i == totalLines - 1 ? contentManager.items.count - 1 : firstItemOfEachLine[i + 1] - 1
}

func hasExactlyOneElement(line i: Int) -> Bool {
startOf(line: i) == endOf(line: i)
}
}
1 change: 1 addition & 0 deletions Sources/WrappingHStack/NewLine.swift
@@ -1,5 +1,6 @@
import SwiftUI

/// Use this item to force a line break in a WrappingHStack
public struct NewLine: View {
public init() { }
public let body = Spacer(minLength: .infinity)
Expand Down

0 comments on commit 9f5f586

Please sign in to comment.