From 122921c606f11902a3801810454ced61cbb968ee Mon Sep 17 00:00:00 2001 From: Murad Date: Tue, 14 May 2024 00:40:26 +0400 Subject: [PATCH] COMPONENT: imported spreadsheetview sources --- Config.json | 4 +- .../PashaKit/SpreadsheetView/Address.swift | 25 + .../SpreadsheetView/Array+BinarySearch.swift | 27 + .../PashaKit/SpreadsheetView/Borders.swift | 107 +++ Sources/PashaKit/SpreadsheetView/Cell.swift | 106 +++ .../PashaKit/SpreadsheetView/CellRange.swift | 69 ++ .../SpreadsheetView/CircularScrolling.swift | 328 +++++++ .../PashaKit/SpreadsheetView/Gridlines.swift | 63 ++ .../SpreadsheetView/IndexPath+Column.swift | 19 + .../SpreadsheetView/LayoutEngine.swift | 576 +++++++++++++ .../PashaKit/SpreadsheetView/Location.swift | 32 + .../PashaKit/SpreadsheetView/ReuseQueue.swift | 62 ++ .../SpreadsheetView/ScrollPosition.swift | 57 ++ .../PashaKit/SpreadsheetView/ScrollView.swift | 84 ++ .../SpreadsheetView+CirclularScrolling.swift | 112 +++ .../SpreadsheetView+Layout.swift | 354 ++++++++ .../SpreadsheetView+Touches.swift | 133 +++ .../SpreadsheetView+UIScrollView.swift | 88 ++ ...SpreadsheetView+UIScrollViewDelegate.swift | 58 ++ .../SpreadsheetView+UISnapshotting.swift | 35 + .../SpreadsheetView+UIViewHierarchy.swift | 39 + .../SpreadsheetView/SpreadsheetView.swift | 808 ++++++++++++++++++ .../SpreadsheetViewDataSource.swift | 71 ++ .../SpreadsheetViewDelegate.swift | 93 ++ 24 files changed, 3348 insertions(+), 2 deletions(-) create mode 100644 Sources/PashaKit/SpreadsheetView/Address.swift create mode 100644 Sources/PashaKit/SpreadsheetView/Array+BinarySearch.swift create mode 100644 Sources/PashaKit/SpreadsheetView/Borders.swift create mode 100644 Sources/PashaKit/SpreadsheetView/Cell.swift create mode 100644 Sources/PashaKit/SpreadsheetView/CellRange.swift create mode 100644 Sources/PashaKit/SpreadsheetView/CircularScrolling.swift create mode 100644 Sources/PashaKit/SpreadsheetView/Gridlines.swift create mode 100644 Sources/PashaKit/SpreadsheetView/IndexPath+Column.swift create mode 100644 Sources/PashaKit/SpreadsheetView/LayoutEngine.swift create mode 100644 Sources/PashaKit/SpreadsheetView/Location.swift create mode 100644 Sources/PashaKit/SpreadsheetView/ReuseQueue.swift create mode 100644 Sources/PashaKit/SpreadsheetView/ScrollPosition.swift create mode 100644 Sources/PashaKit/SpreadsheetView/ScrollView.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetView+CirclularScrolling.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetView+Layout.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetView+Touches.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIScrollView.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIScrollViewDelegate.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetView+UISnapshotting.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIViewHierarchy.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetView.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetViewDataSource.swift create mode 100644 Sources/PashaKit/SpreadsheetView/SpreadsheetViewDelegate.swift diff --git a/Config.json b/Config.json index 331855c..da6ab97 100644 --- a/Config.json +++ b/Config.json @@ -1,4 +1,4 @@ { - "version": "1.5.1", - "release_notes": "Custom fonts exports and related swift files was removed" + "version": "1.5.2", + "release_notes": "Imported `SpreadsheetView` library into codebase" } diff --git a/Sources/PashaKit/SpreadsheetView/Address.swift b/Sources/PashaKit/SpreadsheetView/Address.swift new file mode 100644 index 0000000..2313385 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/Address.swift @@ -0,0 +1,25 @@ +// +// Address.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 3/16/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import Foundation + +struct Address: Hashable { + let row: Int + let column: Int + let rowIndex: Int + let columnIndex: Int + + func hash(into hasher: inout Hasher) { + hasher.combine(rowIndex) + hasher.combine(columnIndex) + } + + static func ==(lhs: Address, rhs: Address) -> Bool { + return lhs.rowIndex == rhs.rowIndex && lhs.columnIndex == rhs.columnIndex + } +} diff --git a/Sources/PashaKit/SpreadsheetView/Array+BinarySearch.swift b/Sources/PashaKit/SpreadsheetView/Array+BinarySearch.swift new file mode 100644 index 0000000..4027b7e --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/Array+BinarySearch.swift @@ -0,0 +1,27 @@ +// +// Array+BinarySearch.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 4/23/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import Foundation + +extension Array where Element: Comparable { + func insertionIndex(of element: Element) -> Int { + var lower = 0 + var upper = count - 1 + while lower <= upper { + let middle = (lower + upper) / 2 + if self[middle] < element { + lower = middle + 1 + } else if element < self[middle] { + upper = middle - 1 + } else { + return middle + } + } + return lower + } +} diff --git a/Sources/PashaKit/SpreadsheetView/Borders.swift b/Sources/PashaKit/SpreadsheetView/Borders.swift new file mode 100644 index 0000000..20260e0 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/Borders.swift @@ -0,0 +1,107 @@ +// +// Borders.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 5/8/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +public struct Borders { + public var top: BorderStyle + public var bottom: BorderStyle + public var left: BorderStyle + public var right: BorderStyle + + public static func all(_ style: BorderStyle) -> Borders { + return Borders(top: style, bottom: style, left: style, right: style) + } +} + +public enum BorderStyle { + case none + case solid(width: CGFloat, color: UIColor) +} + +extension BorderStyle: Equatable { + public static func ==(lhs: BorderStyle, rhs: BorderStyle) -> Bool { + switch (lhs, rhs) { + case (.none, .none): + return true + case let (.solid(lWidth, lColor), .solid(rWidth, rColor)): + return lWidth == rWidth && lColor == rColor + default: + return false + } + } +} + +final class Border: UIView { + var borders: Borders = .all(.none) + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + backgroundColor = .clear + layer.zPosition = 1000 + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override func draw(_ rect: CGRect) { + guard let context = UIGraphicsGetCurrentContext() else { + return + } + context.setFillColor(UIColor.clear.cgColor) + if case let .solid(width, color) = borders.left { + context.move(to: CGPoint(x: width * 0.5, y: 0)) + context.addLine(to: CGPoint(x: width * 0.5, y: bounds.height)) + context.setLineWidth(width) + context.setStrokeColor(color.cgColor) + context.strokePath() + } + if case let .solid(width, color) = borders.right { + context.move(to: CGPoint(x: bounds.width - width * 0.5, y: 0)) + context.addLine(to: CGPoint(x: bounds.width - width * 0.5, y: bounds.height)) + context.setLineWidth(width) + context.setStrokeColor(color.cgColor) + context.strokePath() + } + if case let .solid(width, color) = borders.top { + context.move(to: CGPoint(x: 0, y: width * 0.5)) + context.addLine(to: CGPoint(x: bounds.width, y: width * 0.5)) + context.setLineWidth(width) + context.setStrokeColor(color.cgColor) + context.strokePath() + } + if case let .solid(width, color) = borders.bottom { + context.move(to: CGPoint(x: 0, y: bounds.height - width * 0.5)) + context.addLine(to: CGPoint(x: bounds.width, y: bounds.height - width * 0.5)) + context.setLineWidth(width) + context.setStrokeColor(color.cgColor) + context.strokePath() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + if case let .solid(width, _) = borders.left { + frame.origin.x -= width * 0.5 + frame.size.width += width * 0.5 + } + if case let .solid(width, _) = borders.right { + frame.size.width += width * 0.5 + } + if case let .solid(width, _) = borders.top { + frame.origin.y -= width * 0.5 + frame.size.height += width * 0.5 + } + if case let .solid(width, _) = borders.bottom { + frame.size.height += width * 0.5 + } + } +} diff --git a/Sources/PashaKit/SpreadsheetView/Cell.swift b/Sources/PashaKit/SpreadsheetView/Cell.swift new file mode 100644 index 0000000..11da40b --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/Cell.swift @@ -0,0 +1,106 @@ +// +// Cell.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 3/16/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +open class Cell: UIView { + public let contentView = UIView() + + public var backgroundView: UIView? { + willSet { + backgroundView?.removeFromSuperview() + } + didSet { + guard let backgroundView = backgroundView else { + return + } + backgroundView.frame = bounds + backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + insertSubview(backgroundView, at: 0) + } + } + public var selectedBackgroundView: UIView? { + willSet { + selectedBackgroundView?.removeFromSuperview() + } + didSet { + guard let selectedBackgroundView = selectedBackgroundView else { + return + } + selectedBackgroundView.frame = bounds + selectedBackgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + selectedBackgroundView.alpha = 0 + if let backgroundView = backgroundView { + insertSubview(selectedBackgroundView, aboveSubview: backgroundView) + } else { + insertSubview(selectedBackgroundView, at: 0) + } + } + } + + open var isHighlighted = false { + didSet { + selectedBackgroundView?.alpha = isHighlighted || isSelected ? 1 : 0 + } + } + open var isSelected = false { + didSet { + selectedBackgroundView?.alpha = isSelected ? 1 : 0 + } + } + + public var gridlines = Gridlines(top: .default, bottom: .default, left: .default, right: .default) + public var borders = Borders(top: .none, bottom: .none, left: .none, right: .none) { + didSet { + hasBorder = borders.top != .none || borders.bottom != .none || borders.left != .none || borders.right != .none + } + } + var hasBorder = false + + public internal(set) var reuseIdentifier: String? + + var indexPath: IndexPath! + + public override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + open func setup() { + backgroundColor = .white + + contentView.frame = bounds + contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + insertSubview(contentView, at: 0) + } + + open func prepareForReuse() {} + + func setSelected(_ selected: Bool, animated: Bool) { + if animated { + UIView.animate(withDuration: CATransaction.animationDuration()) { + self.isSelected = selected + } + } else { + isSelected = selected + } + } +} + +extension Cell: Comparable { + public static func <(lhs: Cell, rhs: Cell) -> Bool { + return lhs.indexPath < rhs.indexPath + } +} + +final class BlankCell: Cell {} diff --git a/Sources/PashaKit/SpreadsheetView/CellRange.swift b/Sources/PashaKit/SpreadsheetView/CellRange.swift new file mode 100644 index 0000000..901e3c7 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/CellRange.swift @@ -0,0 +1,69 @@ +// +// CellRange.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 3/16/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +public final class CellRange { + public let from: Location + public let to: Location + + public let columnCount: Int + public let rowCount: Int + + var size: CGSize? + + public convenience init(from: (row: Int, column: Int), to: (row: Int, column: Int)) { + self.init(from: Location(row: from.row, column: from.column), + to: Location(row: to.row, column: to.column)) + } + + public convenience init(from: IndexPath, to: IndexPath) { + self.init(from: Location(row: from.row, column: from.column), + to: Location(row: to.row, column: to.column)) + } + + init(from: Location, to: Location) { + guard from.column <= to.column && from.row <= to.row else { + fatalError("the value of `from` must be less than or equal to the value of `to`") + } + self.from = from + self.to = to + columnCount = to.column - from.column + 1 + rowCount = to.row - from.row + 1 + } + + public func contains(_ indexPath: IndexPath) -> Bool { + return from.column <= indexPath.column && to.column >= indexPath.column && + from.row <= indexPath.row && to.row >= indexPath.row + } + + public func contains(_ cellRange: CellRange) -> Bool { + return from.column <= cellRange.from.column && to.column >= cellRange.to.column && + from.row <= cellRange.from.row && to.row >= cellRange.to.row + } +} + +extension CellRange: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(from) + } + + public static func ==(lhs: CellRange, rhs: CellRange) -> Bool { + return lhs.from == rhs.from + } +} + +extension CellRange: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + return "R\(from.row)C\(from.column):R\(to.row)C\(to.column)" + } + + public var debugDescription: String { + return description + } +} diff --git a/Sources/PashaKit/SpreadsheetView/CircularScrolling.swift b/Sources/PashaKit/SpreadsheetView/CircularScrolling.swift new file mode 100644 index 0000000..135926c --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/CircularScrolling.swift @@ -0,0 +1,328 @@ +// +// CircularScrolling.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 5/6/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import Foundation + +public enum CircularScrolling { + public struct Configuration { + public static var none: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + public static var horizontally: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + public static var vertically: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + public static var both: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + + private init() {} + + public struct Options { + let direction: CircularScrolling.Direction + let headerStyle: CircularScrolling.HeaderStyle + let tableStyle: CircularScrolling.TableStyle + } + } + + public class None: CircularScrollingConfigurationState {} + public class Horizontally: CircularScrollingConfigurationState { + public class ColumnHeaderNotRepeated: CircularScrollingConfigurationState { + public class RowHeaderStartsFirstColumn: CircularScrollingConfigurationState {} + } + public class RowHeaderStartsFirstColumn: CircularScrollingConfigurationState {} + } + public class Vertically: CircularScrollingConfigurationState { + public class RowHeaderNotRepeated: CircularScrollingConfigurationState { + public class ColumnHeaderStartsFirstRow: CircularScrollingConfigurationState {} + } + public class ColumnHeaderStartsFirstRow: CircularScrollingConfigurationState {} + } + + public class Both: CircularScrollingConfigurationState { + public class ColumnHeaderNotRepeated: CircularScrollingConfigurationState { + public class RowHeaderStartsFirstColumn: CircularScrollingConfigurationState {} + public class ColumnHeaderStartsFirstRow: CircularScrollingConfigurationState {} + public class RowHeaderNotRepeated: CircularScrollingConfigurationState { + public class RowHeaderStartsFirstColumn: CircularScrollingConfigurationState {} + public class ColumnHeaderStartsFirstRow: CircularScrollingConfigurationState {} + } + } + public class RowHeaderNotRepeated: CircularScrollingConfigurationState { + public class RowHeaderStartsFirstColumn: CircularScrollingConfigurationState {} + public class ColumnHeaderStartsFirstRow: CircularScrollingConfigurationState {} + public class ColumnHeaderNotRepeated: CircularScrollingConfigurationState { + public class RowHeaderStartsFirstColumn: CircularScrollingConfigurationState {} + public class ColumnHeaderStartsFirstRow: CircularScrollingConfigurationState {} + } + } + public class RowHeaderStartsFirstColumn: CircularScrollingConfigurationState { + public class RowHeaderNotRepeated: CircularScrollingConfigurationState {} + } + public class ColumnHeaderStartsFirstRow: CircularScrollingConfigurationState { + public class ColumnHeaderNotRepeated: CircularScrollingConfigurationState {} + } + } + + struct Direction: OptionSet { + static var vertically = Direction(rawValue: 1 << 0) + static var horizontally = Direction(rawValue: 1 << 1) + static var both: Direction = [.vertically, .horizontally] + + let rawValue: Int + init(rawValue: Int) { + self.rawValue = rawValue + } + } + + enum HeaderStyle { + case none + case columnHeaderStartsFirstRow + case rowHeaderStartsFirstColumn + } + + struct TableStyle: OptionSet { + static var columnHeaderNotRepeated = TableStyle(rawValue: 1 << 0) + static var rowHeaderNotRepeated = TableStyle(rawValue: 1 << 1) + + let rawValue: Int + init(rawValue: Int) { + self.rawValue = rawValue + } + } +} + +public protocol CircularScrollingConfigurationState {} +public protocol CircularScrollingConfiguration { + var options: CircularScrolling.Configuration.Options { get } +} + +public class CircularScrollingConfigurationBuilder : CircularScrollingConfiguration { + public var options: CircularScrolling.Configuration.Options { + switch T.self { + case is CircularScrolling.None.Type: + return CircularScrolling.Configuration.Options(direction: [], headerStyle: .none, tableStyle: []) + case is CircularScrolling.Horizontally.Type: + return CircularScrolling.Configuration.Options(direction: [.horizontally], headerStyle: .none, tableStyle: []) + case is CircularScrolling.Horizontally.RowHeaderStartsFirstColumn.Type: + return CircularScrolling.Configuration.Options(direction: [.horizontally], headerStyle: .rowHeaderStartsFirstColumn, tableStyle: [.columnHeaderNotRepeated]) + case is CircularScrolling.Horizontally.ColumnHeaderNotRepeated.Type: + return CircularScrolling.Configuration.Options(direction: [.horizontally], headerStyle: .none, tableStyle: [.columnHeaderNotRepeated]) + case is CircularScrolling.Horizontally.ColumnHeaderNotRepeated.RowHeaderStartsFirstColumn.Type: + return CircularScrolling.Configuration.Options(direction: [.horizontally], headerStyle: .rowHeaderStartsFirstColumn, tableStyle: [.columnHeaderNotRepeated]) + case is CircularScrolling.Vertically.Type: + return CircularScrolling.Configuration.Options(direction: [.vertically], headerStyle: .none, tableStyle: []) + case is CircularScrolling.Vertically.ColumnHeaderStartsFirstRow.Type: + return CircularScrolling.Configuration.Options(direction: [.vertically], headerStyle: .columnHeaderStartsFirstRow, tableStyle: [.rowHeaderNotRepeated]) + case is CircularScrolling.Vertically.RowHeaderNotRepeated.Type: + return CircularScrolling.Configuration.Options(direction: [.vertically], headerStyle: .none, tableStyle: [.rowHeaderNotRepeated]) + case is CircularScrolling.Vertically.RowHeaderNotRepeated.ColumnHeaderStartsFirstRow.Type: + return CircularScrolling.Configuration.Options(direction: [.vertically], headerStyle: .columnHeaderStartsFirstRow, tableStyle: [.rowHeaderNotRepeated]) + case is CircularScrolling.Both.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .none, tableStyle: []) + case is CircularScrolling.Both.RowHeaderStartsFirstColumn.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .rowHeaderStartsFirstColumn, tableStyle: [.columnHeaderNotRepeated]) + case is CircularScrolling.Both.ColumnHeaderStartsFirstRow.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .columnHeaderStartsFirstRow, tableStyle: [.rowHeaderNotRepeated]) + case is CircularScrolling.Both.RowHeaderStartsFirstColumn.RowHeaderNotRepeated.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .rowHeaderStartsFirstColumn, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.ColumnHeaderStartsFirstRow.ColumnHeaderNotRepeated.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .columnHeaderStartsFirstRow, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.ColumnHeaderNotRepeated.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .none, tableStyle: [.columnHeaderNotRepeated]) + case is CircularScrolling.Both.ColumnHeaderNotRepeated.RowHeaderStartsFirstColumn.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .rowHeaderStartsFirstColumn, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.ColumnHeaderNotRepeated.ColumnHeaderStartsFirstRow.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .columnHeaderStartsFirstRow, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.ColumnHeaderNotRepeated.RowHeaderNotRepeated.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .none, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.ColumnHeaderNotRepeated.RowHeaderNotRepeated.RowHeaderStartsFirstColumn.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .rowHeaderStartsFirstColumn, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.ColumnHeaderNotRepeated.RowHeaderNotRepeated.ColumnHeaderStartsFirstRow.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .columnHeaderStartsFirstRow, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.RowHeaderNotRepeated.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .none, tableStyle: [.rowHeaderNotRepeated]) + case is CircularScrolling.Both.RowHeaderNotRepeated.RowHeaderStartsFirstColumn.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .rowHeaderStartsFirstColumn, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.RowHeaderNotRepeated.ColumnHeaderStartsFirstRow.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .columnHeaderStartsFirstRow, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.RowHeaderNotRepeated.ColumnHeaderNotRepeated.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .none, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.RowHeaderNotRepeated.ColumnHeaderNotRepeated.RowHeaderStartsFirstColumn.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .rowHeaderStartsFirstColumn, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + case is CircularScrolling.Both.RowHeaderNotRepeated.ColumnHeaderNotRepeated.ColumnHeaderStartsFirstRow.Type: + return CircularScrolling.Configuration.Options(direction: [.both], headerStyle: .columnHeaderStartsFirstRow, tableStyle: [.columnHeaderNotRepeated, .rowHeaderNotRepeated]) + default: + return CircularScrolling.Configuration.Options(direction: [], headerStyle: .none, tableStyle: []) + } + } +} + +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Horizontally { + var rowHeaderStartsFirstColumn: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var columnHeaderNotRepeated: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Horizontally.RowHeaderStartsFirstColumn {} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Horizontally.ColumnHeaderNotRepeated { + var rowHeaderStartsFirstColumn: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Horizontally.ColumnHeaderNotRepeated.RowHeaderStartsFirstColumn {} + +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Vertically { + var columnHeaderStartsFirstRow: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var rowHeaderNotRepeated: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Vertically.ColumnHeaderStartsFirstRow {} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Vertically.RowHeaderNotRepeated { + var columnHeaderStartsFirstRow: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Vertically.RowHeaderNotRepeated.ColumnHeaderStartsFirstRow {} + +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both { + var rowHeaderStartsFirstColumn: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var columnHeaderStartsFirstRow: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var columnHeaderNotRepeated: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var rowHeaderNotRepeated: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.RowHeaderStartsFirstColumn { + var rowHeaderNotRepeated: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.ColumnHeaderStartsFirstRow { + var columnHeaderNotRepeated: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.RowHeaderStartsFirstColumn.RowHeaderNotRepeated {} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.ColumnHeaderStartsFirstRow.ColumnHeaderNotRepeated {} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.ColumnHeaderNotRepeated { + var rowHeaderNotRepeated: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var rowHeaderStartsFirstColumn: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var columnHeaderStartsFirstRow: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.ColumnHeaderNotRepeated.RowHeaderNotRepeated { + var rowHeaderStartsFirstColumn: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var columnHeaderStartsFirstRow: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.ColumnHeaderNotRepeated.RowHeaderNotRepeated.RowHeaderStartsFirstColumn {} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.ColumnHeaderNotRepeated.RowHeaderNotRepeated.ColumnHeaderStartsFirstRow {} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.RowHeaderNotRepeated { + var columnHeaderNotRepeated: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var rowHeaderStartsFirstColumn: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var columnHeaderStartsFirstRow: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.RowHeaderNotRepeated.ColumnHeaderNotRepeated { + var rowHeaderStartsFirstColumn: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } + var columnHeaderStartsFirstRow: CircularScrollingConfigurationBuilder { + return CircularScrollingConfigurationBuilder() + } +} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.RowHeaderNotRepeated.ColumnHeaderNotRepeated.RowHeaderStartsFirstColumn {} +public extension CircularScrollingConfigurationBuilder where T: CircularScrolling.Both.RowHeaderNotRepeated.ColumnHeaderNotRepeated.ColumnHeaderStartsFirstRow {} + +extension CircularScrollingConfigurationBuilder: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + return "(direction: \(options.direction), tableStyle: \(options.tableStyle), headerStyle: \(options.headerStyle))" + } + + public var debugDescription: String { + return description + } +} + +extension CircularScrolling.Direction: CustomStringConvertible, CustomDebugStringConvertible { + var description: String { + var options = [String]() + if contains(.vertically) { + options.append(".vertically") + } + if contains(.horizontally) { + options.append(".horizontally") + } + return options.description + } + + var debugDescription: String { + return description + } +} + +extension CircularScrolling.TableStyle: CustomStringConvertible, CustomDebugStringConvertible { + var description: String { + var options = [String]() + if contains(.columnHeaderNotRepeated) { + options.append(".columnHeaderNotRepeated") + } + if contains(.rowHeaderNotRepeated) { + options.append(".rowHeaderNotRepeated") + } + return options.description + } + + var debugDescription: String { + return description + } +} + +extension CircularScrolling.HeaderStyle: CustomStringConvertible, CustomDebugStringConvertible { + var description: String { + switch self { + case .none: + return ".none" + case .columnHeaderStartsFirstRow: + return ".columnHeaderStartsFirstRow" + case .rowHeaderStartsFirstColumn: + return ".rowHeaderStartsFirstColumn" + } + } + + var debugDescription: String { + return description + } +} diff --git a/Sources/PashaKit/SpreadsheetView/Gridlines.swift b/Sources/PashaKit/SpreadsheetView/Gridlines.swift new file mode 100644 index 0000000..ed6a5dc --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/Gridlines.swift @@ -0,0 +1,63 @@ +// +// Gridlines.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 5/7/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +public struct Gridlines { + public var top: GridStyle + public var bottom: GridStyle + public var left: GridStyle + public var right: GridStyle + + public static func all(_ style: GridStyle) -> Gridlines { + return Gridlines(top: style, bottom: style, left: style, right: style) + } +} + +public enum GridStyle { + case `default` + case none + case solid(width: CGFloat, color: UIColor) +} + +extension GridStyle: Equatable { + public static func ==(lhs: GridStyle, rhs: GridStyle) -> Bool { + switch (lhs, rhs) { + case (.none, .none): + return true + case let (.solid(lWidth, lColor), .solid(rWidth, rColor)): + return lWidth == rWidth && lColor == rColor + default: + return false + } + } +} + +final class Gridline: CALayer { + var color: UIColor = .clear { + didSet { + backgroundColor = color.cgColor + } + } + + override init() { + super.init() + } + + override init(layer: Any) { + super.init(layer: layer) + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override func action(forKey event: String) -> CAAction? { + return nil + } +} diff --git a/Sources/PashaKit/SpreadsheetView/IndexPath+Column.swift b/Sources/PashaKit/SpreadsheetView/IndexPath+Column.swift new file mode 100644 index 0000000..9ddf82b --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/IndexPath+Column.swift @@ -0,0 +1,19 @@ +// +// IndexPath+Column.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 4/23/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import Foundation + +public extension IndexPath { + var column: Int { + return section + } + + init(row: Int, column: Int) { + self.init(row: row, section: column) + } +} diff --git a/Sources/PashaKit/SpreadsheetView/LayoutEngine.swift b/Sources/PashaKit/SpreadsheetView/LayoutEngine.swift new file mode 100644 index 0000000..d3c9a3a --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/LayoutEngine.swift @@ -0,0 +1,576 @@ +// +// LayoutEngine.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 5/7/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +final class LayoutEngine { + private let spreadsheetView: SpreadsheetView + private let scrollView: ScrollView + + private let intercellSpacing: CGSize + private let defaultGridStyle: GridStyle + private let circularScrollingOptions: CircularScrolling.Configuration.Options + private let blankCellReuseIdentifier: String + private let highlightedIndexPaths: Set + private let selectedIndexPaths: Set + + private let frozenColumns: Int + private let frozenRows: Int + + private let columnWidthCache: [CGFloat] + private let rowHeightCache: [CGFloat] + + private let visibleRect: CGRect + private var cellOrigin: CGPoint + + private let startColumn: Int + private let startRow: Int + private let numberOfColumns: Int + private let numberOfRows: Int + private let columnCount: Int + private let rowCount: Int + private let insets: CGPoint + + private let columnRecords: [CGFloat] + private let rowRecords: [CGFloat] + + private var mergedCellAddresses = Set
() + private var mergedCellRects = [Address: CGRect]() + + private var visibleCellAddresses = Set
() + + private var horizontalGridLayouts = [Address: GridLayout]() + private var verticalGridLayouts = [Address: GridLayout]() + + private var visibleHorizontalGridAddresses = Set
() + private var visibleVerticalGridAddresses = Set
() + private var visibleBorderAddresses = Set
() + + init(spreadsheetView: SpreadsheetView, scrollView: ScrollView) { + self.spreadsheetView = spreadsheetView + self.scrollView = scrollView + + intercellSpacing = spreadsheetView.intercellSpacing + defaultGridStyle = spreadsheetView.gridStyle + circularScrollingOptions = spreadsheetView.circularScrollingOptions + blankCellReuseIdentifier = spreadsheetView.blankCellReuseIdentifier + highlightedIndexPaths = spreadsheetView.highlightedIndexPaths + selectedIndexPaths = spreadsheetView.selectedIndexPaths + + frozenColumns = spreadsheetView.layoutProperties.frozenColumns + frozenRows = spreadsheetView.layoutProperties.frozenRows + columnWidthCache = spreadsheetView.layoutProperties.columnWidthCache + rowHeightCache = spreadsheetView.layoutProperties.rowHeightCache + + visibleRect = CGRect(origin: scrollView.state.contentOffset, size: scrollView.state.frame.size) + cellOrigin = .zero + + startColumn = scrollView.layoutAttributes.startColumn + startRow = scrollView.layoutAttributes.startRow + numberOfColumns = scrollView.layoutAttributes.numberOfColumns + numberOfRows = scrollView.layoutAttributes.numberOfRows + columnCount = scrollView.layoutAttributes.columnCount + rowCount = scrollView.layoutAttributes.rowCount + insets = scrollView.layoutAttributes.insets + + columnRecords = scrollView.columnRecords + rowRecords = scrollView.rowRecords + } + + func layout() { + guard startColumn != columnCount && startRow != rowCount else { + return + } + + let startRowIndex = spreadsheetView.findIndex(in: scrollView.rowRecords, for: visibleRect.origin.y - insets.y) + cellOrigin.y = insets.y + scrollView.rowRecords[startRowIndex] + intercellSpacing.height + + for rowIndex in (startRowIndex + startRow).. 0 && row < frozenRows { + continue + } + + let stop = enumerateColumns(currentRow: row, currentRowIndex: rowIndex) + if stop { + break + } + cellOrigin.y += rowHeightCache[row] + intercellSpacing.height + } + + renderMergedCells() + renderVerticalGridlines() + renderHorizontalGridlines() + renderBorders() + returnReusableResouces() + } + + private func enumerateColumns(currentRow row: Int, currentRowIndex rowIndex: Int) -> Bool { + let startColumnIndex = spreadsheetView.findIndex(in: columnRecords, for: visibleRect.origin.x - insets.x) + cellOrigin.x = insets.x + columnRecords[startColumnIndex] + intercellSpacing.width + + var columnIndex = startColumnIndex + startColumn + + while columnIndex < columnCount { + var columnStep = 1 + defer { + columnIndex += columnStep + } + + let column = columnIndex % numberOfColumns + if circularScrollingOptions.tableStyle.contains(.columnHeaderNotRepeated) && startColumn > 0 && column < frozenColumns { + continue + } + + let columnWidth = columnWidthCache[column] + + if let mergedCell = spreadsheetView.mergedCell(for: Location(row: row, column: column)) { + var cellWidth: CGFloat = 0 + var cellHeight: CGFloat = 0 + if let cellSize = mergedCell.size { + cellWidth = cellSize.width + cellHeight = cellSize.height + } else { + for column in mergedCell.from.column...mergedCell.to.column { + cellWidth += columnWidthCache[column] + intercellSpacing.width + } + for row in mergedCell.from.row...mergedCell.to.row { + cellHeight += rowHeightCache[row] + intercellSpacing.height + } + mergedCell.size = CGSize(width: cellWidth, height: cellHeight) + } + + columnStep += (mergedCell.columnCount - (column - mergedCell.from.column)) - 1 + let address = Address(row: mergedCell.from.row, column: mergedCell.from.column, + rowIndex: rowIndex - (row - mergedCell.from.row), columnIndex: columnIndex - (column - mergedCell.from.column)) + + if column < columnRecords.count { + let offsetWidth = columnRecords[column - startColumn] - columnRecords[mergedCell.from.column - startColumn] + cellOrigin.x -= offsetWidth + } else { + let fromColumn = mergedCell.from.column + let endColumn = columnRecords.count - 1 + + var offsetWidth = columnRecords[endColumn] + for column in endColumn.. visibleRect.minX else { + cellOrigin.x += cellWidth + continue + } + guard cellOrigin.x <= visibleRect.maxX else { + cellOrigin.x += cellWidth + return false + } + guard cellOrigin.y - offsetHeight + cellHeight - intercellSpacing.height > visibleRect.minY else { + cellOrigin.x += cellWidth + continue + } + guard cellOrigin.y - offsetHeight <= visibleRect.maxY else { + return true + } + + visibleCellAddresses.insert(address) + if mergedCellAddresses.insert(address).inserted { + mergedCellRects[address] = CGRect(origin: CGPoint(x: cellOrigin.x, y: cellOrigin.y - offsetHeight), + size: CGSize(width: cellWidth - intercellSpacing.width, height: cellHeight - intercellSpacing.height)) + } + + cellOrigin.x += cellWidth + continue + } + + let rowHeight = rowHeightCache[row] + + guard cellOrigin.x + columnWidth > visibleRect.minX else { + cellOrigin.x += columnWidth + intercellSpacing.width + continue + } + guard cellOrigin.x <= visibleRect.maxX else { + cellOrigin.x += columnWidth + intercellSpacing.width + return false + } + guard cellOrigin.y + rowHeight > visibleRect.minY else { + cellOrigin.x += columnWidth + intercellSpacing.width + continue + } + guard cellOrigin.y <= visibleRect.maxY else { + return true + } + + let address = Address(row: row, column: column, rowIndex: rowIndex, columnIndex: columnIndex) + visibleCellAddresses.insert(address) + + let cellSize = CGSize(width: columnWidth, height: rowHeight) + layoutCell(address: address, frame: CGRect(origin: cellOrigin, size: cellSize)) + + cellOrigin.x += columnWidth + intercellSpacing.width + } + + return false + } + + private func layoutCell(address: Address, frame: CGRect) { + guard let dataSource = spreadsheetView.dataSource else { + return + } + + let gridlines: Gridlines? + let border: (borders: Borders?, hasBorders: Bool) + + if scrollView.visibleCells.contains(address) { + if let cell = scrollView.visibleCells[address] { + cell.frame = frame + gridlines = cell.gridlines + border = (cell.borders, cell.hasBorder) + } else { + gridlines = nil + border = (nil, false) + } + } else { + let indexPath = IndexPath(row: address.row, column: address.column) + + let cell = dataSource.spreadsheetView(spreadsheetView, cellForItemAt: indexPath) ?? spreadsheetView.dequeueReusableCell(withReuseIdentifier: blankCellReuseIdentifier, for: indexPath) + guard let _ = cell.reuseIdentifier else { + fatalError("the cell returned from `spreadsheetView(_:cellForItemAt:)` does not have a `reuseIdentifier` - cells must be retrieved by calling `dequeueReusableCell(withReuseIdentifier:for:)`") + } + cell.indexPath = indexPath + cell.frame = frame + cell.isHighlighted = highlightedIndexPaths.contains(indexPath) + cell.isSelected = selectedIndexPaths.contains(indexPath) + + gridlines = cell.gridlines + border = (cell.borders, cell.hasBorder) + + scrollView.insertSubview(cell, at: 0) + scrollView.visibleCells[address] = cell + } + + if border.hasBorders { + visibleBorderAddresses.insert(address) + } + if let gridlines = gridlines { + layoutGridlines(address: address, frame: frame, gridlines: gridlines) + } + } + + private func layoutGridlines(address: Address, frame: CGRect, gridlines: Gridlines) { + let (topWidth, topColor, topPriority) = extractGridStyle(style: gridlines.top) + let (bottomWidth, bottomColor, bottomPriority) = extractGridStyle(style: gridlines.bottom) + let (leftWidth, leftColor, leftPriority) = extractGridStyle(style: gridlines.left) + let (rightWidth, rightColor, rightPriority) = extractGridStyle(style: gridlines.right) + + if let gridLayout = horizontalGridLayouts[address] { + if topPriority > gridLayout.priority { + horizontalGridLayouts[address] = GridLayout(gridWidth: topWidth, gridColor: topColor, origin: frame.origin, length: frame.width, edge: .top(left: leftWidth, right: rightWidth), priority: topPriority) + } + } else { + horizontalGridLayouts[address] = GridLayout(gridWidth: topWidth, gridColor: topColor, origin: frame.origin, length: frame.width, edge: .top(left: leftWidth, right: rightWidth), priority: topPriority) + } + let underCellAddress = Address(row: address.row + 1, column: address.column, rowIndex: address.rowIndex + 1, columnIndex: address.columnIndex) + if let gridLayout = horizontalGridLayouts[underCellAddress] { + if bottomPriority > gridLayout.priority { + horizontalGridLayouts[underCellAddress] = GridLayout(gridWidth: bottomWidth, gridColor: bottomColor, origin: CGPoint(x: frame.origin.x, y: frame.maxY), length: frame.width, edge: .bottom(left: leftWidth, right: rightWidth), priority: bottomPriority) + } + } else { + horizontalGridLayouts[underCellAddress] = GridLayout(gridWidth: bottomWidth, gridColor: bottomColor, origin: CGPoint(x: frame.origin.x, y: frame.maxY), length: frame.width, edge: .bottom(left: leftWidth, right: rightWidth), priority: bottomPriority) + } + if let gridLayout = verticalGridLayouts[address] { + if leftPriority > gridLayout.priority { + verticalGridLayouts[address] = GridLayout(gridWidth: leftWidth, gridColor: leftColor, origin: frame.origin, length: frame.height, edge: .left(top: topWidth, bottom: bottomWidth), priority: leftPriority) + } + } else { + verticalGridLayouts[address] = GridLayout(gridWidth: leftWidth, gridColor: leftColor, origin: frame.origin, length: frame.height, edge: .left(top: topWidth, bottom: bottomWidth), priority: leftPriority) + } + let nextCellAddress = Address(row: address.row, column: address.column + 1, rowIndex: address.rowIndex, columnIndex: address.columnIndex + 1) + if let gridLayout = verticalGridLayouts[nextCellAddress] { + if rightPriority > gridLayout.priority { + verticalGridLayouts[nextCellAddress] = GridLayout(gridWidth: rightWidth, gridColor: rightColor, origin: CGPoint(x: frame.maxX, y: frame.origin.y), length: frame.height, edge: .right(top: topWidth, bottom: bottomWidth), priority: rightPriority) + } + } else { + verticalGridLayouts[nextCellAddress] = GridLayout(gridWidth: rightWidth, gridColor: rightColor, origin: CGPoint(x: frame.maxX, y: frame.origin.y), length: frame.height, edge: .right(top: topWidth, bottom: bottomWidth), priority: rightPriority) + } + } + + private func renderMergedCells() { + for address in mergedCellAddresses { + if let frame = mergedCellRects[address] { + layoutCell(address: address, frame: frame) + } + } + } + + private func renderHorizontalGridlines() { + for (address, gridLayout) in horizontalGridLayouts { + var frame = CGRect.zero + frame.origin = gridLayout.origin + if case let .top(leftWidth, rightWidth) = gridLayout.edge { + frame.origin.x -= leftWidth + (intercellSpacing.width - leftWidth) / 2 + frame.origin.y -= intercellSpacing.height - (intercellSpacing.height - gridLayout.gridWidth) / 2 + frame.size.width = gridLayout.length + leftWidth + (intercellSpacing.width - leftWidth) / 2 + rightWidth + (intercellSpacing.width - rightWidth) / 2 + } + if case let .bottom(leftWidth, rightWidth) = gridLayout.edge { + frame.origin.x -= leftWidth + (intercellSpacing.width - leftWidth) / 2 + frame.origin.y -= (gridLayout.gridWidth - intercellSpacing.height) / 2 + frame.size.width = gridLayout.length + leftWidth + (intercellSpacing.width - leftWidth) / 2 + rightWidth + (intercellSpacing.width - rightWidth) / 2 + } + frame.size.height = gridLayout.gridWidth + + if scrollView.visibleHorizontalGridlines.contains(address) { + if let gridline = scrollView.visibleHorizontalGridlines[address] { + gridline.frame = frame + gridline.color = gridLayout.gridColor + gridline.zPosition = gridLayout.priority + } + } else { + let gridline = spreadsheetView.horizontalGridlineReuseQueue.dequeueOrCreate() + gridline.frame = frame + gridline.color = gridLayout.gridColor + gridline.zPosition = gridLayout.priority + + scrollView.layer.addSublayer(gridline) + scrollView.visibleHorizontalGridlines[address] = gridline + } + visibleHorizontalGridAddresses.insert(address) + } + } + + private func renderVerticalGridlines() { + for (address, gridLayout) in verticalGridLayouts { + var frame = CGRect.zero + frame.origin = gridLayout.origin + if case let .left(topWidth, bottomWidth) = gridLayout.edge { + frame.origin.x -= intercellSpacing.width - (intercellSpacing.width - gridLayout.gridWidth) / 2 + frame.origin.y -= topWidth + (intercellSpacing.height - topWidth) / 2 + frame.size.height = gridLayout.length + topWidth + (intercellSpacing.height - topWidth) / 2 + bottomWidth + (intercellSpacing.height - bottomWidth) / 2 + } + if case let .right(topWidth, bottomWidth) = gridLayout.edge { + frame.origin.x -= (gridLayout.gridWidth - intercellSpacing.width) / 2 + frame.origin.y -= topWidth + (intercellSpacing.height - topWidth) / 2 + frame.size.height = gridLayout.length + topWidth + (intercellSpacing.height - topWidth) / 2 + bottomWidth + (intercellSpacing.height - bottomWidth) / 2 + } + frame.size.width = gridLayout.gridWidth + + if scrollView.visibleVerticalGridlines.contains(address) { + if let gridline = scrollView.visibleVerticalGridlines[address] { + gridline.frame = frame + gridline.color = gridLayout.gridColor + gridline.zPosition = gridLayout.priority + } + } else { + let gridline = spreadsheetView.verticalGridlineReuseQueue.dequeueOrCreate() + gridline.frame = frame + gridline.color = gridLayout.gridColor + gridline.zPosition = gridLayout.priority + + scrollView.layer.addSublayer(gridline) + scrollView.visibleVerticalGridlines[address] = gridline + } + visibleVerticalGridAddresses.insert(address) + } + } + + private func renderBorders() { + for address in visibleBorderAddresses { + if let cell = scrollView.visibleCells[address] { + if scrollView.visibleBorders.contains(address) { + if let border = scrollView.visibleBorders[address] { + border.borders = cell.borders + border.frame = cell.frame + border.setNeedsDisplay() + } + } else { + let border = spreadsheetView.borderReuseQueue.dequeueOrCreate() + border.borders = cell.borders + border.frame = cell.frame + scrollView.addSubview(border) + scrollView.visibleBorders[address] = border + } + } + } + } + + private func extractGridStyle(style: GridStyle) -> (width: CGFloat, color: UIColor, priority: CGFloat) { + let gridWidth: CGFloat + let gridColor: UIColor + let priority: CGFloat + switch style { + case .default: + switch defaultGridStyle { + case let .solid(width, color): + gridWidth = width + gridColor = color + priority = 0 + default: + gridWidth = 0 + gridColor = .clear + priority = 0 + } + case let .solid(width, color): + gridWidth = width + gridColor = color + priority = 200 + case .none: + gridWidth = 0 + gridColor = .clear + priority = 100 + } + return (gridWidth, gridColor, priority) + } + + private func returnReusableResouces() { + scrollView.visibleCells.subtract(visibleCellAddresses) + for address in scrollView.visibleCells.addresses { + if let cell = scrollView.visibleCells[address] { + cell.removeFromSuperview() + if let reuseIdentifier = cell.reuseIdentifier, let reuseQueue = spreadsheetView.cellReuseQueues[reuseIdentifier] { + reuseQueue.enqueue(cell) + } + scrollView.visibleCells[address] = nil + } + } + scrollView.visibleCells.addresses = visibleCellAddresses + + scrollView.visibleVerticalGridlines.subtract(visibleVerticalGridAddresses) + for address in scrollView.visibleVerticalGridlines.addresses { + if let gridline = scrollView.visibleVerticalGridlines[address] { + gridline.removeFromSuperlayer() + spreadsheetView.verticalGridlineReuseQueue.enqueue(gridline) + scrollView.visibleVerticalGridlines[address] = nil + } + } + scrollView.visibleVerticalGridlines.addresses = visibleVerticalGridAddresses + + scrollView.visibleHorizontalGridlines.subtract(visibleHorizontalGridAddresses) + for address in scrollView.visibleHorizontalGridlines.addresses { + if let gridline = scrollView.visibleHorizontalGridlines[address] { + gridline.removeFromSuperlayer() + spreadsheetView.horizontalGridlineReuseQueue.enqueue(gridline) + scrollView.visibleHorizontalGridlines[address] = nil + } + } + scrollView.visibleHorizontalGridlines.addresses = visibleHorizontalGridAddresses + + scrollView.visibleBorders.subtract(visibleBorderAddresses) + for address in scrollView.visibleBorders.addresses { + if let border = scrollView.visibleBorders[address] { + border.removeFromSuperview() + spreadsheetView.borderReuseQueue.enqueue(border) + scrollView.visibleBorders[address] = nil + } + } + scrollView.visibleBorders.addresses = visibleBorderAddresses + } +} + +struct LayoutProperties { + let numberOfColumns: Int + let numberOfRows: Int + let frozenColumns: Int + let frozenRows: Int + + let frozenColumnWidth: CGFloat + let frozenRowHeight: CGFloat + let columnWidth: CGFloat + let rowHeight: CGFloat + let columnWidthCache: [CGFloat] + let rowHeightCache: [CGFloat] + + let mergedCells: [CellRange] + let mergedCellLayouts: [Location: CellRange] + + init(numberOfColumns: Int = 0, numberOfRows: Int = 0, + frozenColumns: Int = 0, frozenRows: Int = 0, + frozenColumnWidth: CGFloat = 0, frozenRowHeight: CGFloat = 0, + columnWidth: CGFloat = 0, rowHeight: CGFloat = 0, + columnWidthCache: [CGFloat] = [], rowHeightCache: [CGFloat] = [], + mergedCells: [CellRange] = [], mergedCellLayouts: [Location: CellRange] = [:]) { + self.numberOfColumns = numberOfColumns + self.numberOfRows = numberOfRows + self.frozenColumns = frozenColumns + self.frozenRows = frozenRows + self.frozenColumnWidth = frozenColumnWidth + self.frozenRowHeight = frozenRowHeight + self.columnWidth = columnWidth + self.rowHeight = rowHeight + self.columnWidthCache = columnWidthCache + self.rowHeightCache = rowHeightCache + self.mergedCells = mergedCells + self.mergedCellLayouts = mergedCellLayouts + } +} + +struct LayoutAttributes { + let startColumn: Int + let startRow: Int + let numberOfColumns: Int + let numberOfRows: Int + let columnCount: Int + let rowCount: Int + let insets: CGPoint +} + +enum RectEdge { + case top(left: CGFloat, right: CGFloat) + case bottom(left: CGFloat, right: CGFloat) + case left(top: CGFloat, bottom: CGFloat) + case right(top: CGFloat, bottom: CGFloat) +} + +struct GridLayout { + let gridWidth: CGFloat + let gridColor: UIColor + let origin: CGPoint + let length: CGFloat + let edge: RectEdge + let priority: CGFloat +} diff --git a/Sources/PashaKit/SpreadsheetView/Location.swift b/Sources/PashaKit/SpreadsheetView/Location.swift new file mode 100644 index 0000000..f95c57f --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/Location.swift @@ -0,0 +1,32 @@ +// +// Location.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 4/19/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +public struct Location: Hashable { + public let row: Int + public let column: Int + + init(row: Int, column: Int) { + self.row = row + self.column = column + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(row) + hasher.combine(column) + } + + init(indexPath: IndexPath) { + self.init(row: indexPath.row, column: indexPath.column) + } + + public static func ==(lhs: Location, rhs: Location) -> Bool { + return lhs.row == rhs.row && lhs.column == rhs.column + } +} diff --git a/Sources/PashaKit/SpreadsheetView/ReuseQueue.swift b/Sources/PashaKit/SpreadsheetView/ReuseQueue.swift new file mode 100644 index 0000000..1ed0224 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/ReuseQueue.swift @@ -0,0 +1,62 @@ +// +// ReuseQueue.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 4/16/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import Foundation + +final class ReuseQueue where Reusable: NSObject { + var reusableObjects = Set() + + func enqueue(_ reusableObject: Reusable) { + reusableObjects.insert(reusableObject) + } + + func dequeue() -> Reusable? { + if let _ = reusableObjects.first { + return reusableObjects.removeFirst() + } + return nil + } + + func dequeueOrCreate() -> Reusable { + if let _ = reusableObjects.first { + return reusableObjects.removeFirst() + } + return Reusable() + } +} + +final class ReusableCollection: Sequence where Reusable: NSObject { + var pairs = [Address: Reusable]() + var addresses = Set
() + + func contains(_ member: Address) -> Bool { + return addresses.contains(member) + } + + @discardableResult + func insert(_ newMember: Address) -> (inserted: Bool, memberAfterInsert: Address) { + return addresses.insert(newMember) + } + + func subtract(_ other: Set
) { + addresses.subtract(other) + } + + subscript(key: Address) -> Reusable? { + get { + return pairs[key] + } + set(newValue) { + pairs[key] = newValue + } + } + + func makeIterator() -> AnyIterator { + return AnyIterator(pairs.values.makeIterator()) + } +} diff --git a/Sources/PashaKit/SpreadsheetView/ScrollPosition.swift b/Sources/PashaKit/SpreadsheetView/ScrollPosition.swift new file mode 100644 index 0000000..e50df09 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/ScrollPosition.swift @@ -0,0 +1,57 @@ +// +// ScrollPosition.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 4/23/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import Foundation + +public struct ScrollPosition: OptionSet { + // The vertical positions are mutually exclusive to each other, but are bitwise or-able with the horizontal scroll positions. + // Combining positions from the same grouping (horizontal or vertical) will result in an NSInvalidArgumentException. + public static var top = ScrollPosition(rawValue: 1 << 0) + public static var centeredVertically = ScrollPosition(rawValue: 1 << 1) + public static var bottom = ScrollPosition(rawValue: 1 << 2) + + // Likewise, the horizontal positions are mutually exclusive to each other. + public static var left = ScrollPosition(rawValue: 1 << 3) + public static var centeredHorizontally = ScrollPosition(rawValue: 1 << 4) + public static var right = ScrollPosition(rawValue: 1 << 5) + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} + +extension ScrollPosition: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + var options = [String]() + if contains(.top) { + options.append(".top") + } + if contains(.centeredVertically) { + options.append(".centeredVertically") + } + if contains(.bottom) { + options.append(".bottom") + } + if contains(.left) { + options.append(".left") + } + if contains(.centeredHorizontally) { + options.append(".centeredHorizontally") + } + if contains(.right) { + options.append(".right") + } + return options.description + } + + public var debugDescription: String { + return description + } +} diff --git a/Sources/PashaKit/SpreadsheetView/ScrollView.swift b/Sources/PashaKit/SpreadsheetView/ScrollView.swift new file mode 100644 index 0000000..f21c587 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/ScrollView.swift @@ -0,0 +1,84 @@ +// +// ScrollView.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 3/16/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +final class ScrollView: UIScrollView, UIGestureRecognizerDelegate { + var columnRecords = [CGFloat]() + var rowRecords = [CGFloat]() + + var visibleCells = ReusableCollection() + var visibleVerticalGridlines = ReusableCollection() + var visibleHorizontalGridlines = ReusableCollection() + var visibleBorders = ReusableCollection() + + typealias TouchHandler = (_ touches: Set, _ event: UIEvent?) -> Void + var touchesBegan: TouchHandler? + var touchesEnded: TouchHandler? + var touchesCancelled: TouchHandler? + + var layoutAttributes = LayoutAttributes(startColumn: 0, startRow: 0, numberOfColumns: 0, numberOfRows: 0, columnCount: 0, rowCount: 0, insets: .zero) + var state = State() + struct State { + var frame = CGRect.zero + var contentSize = CGSize.zero + var contentOffset = CGPoint.zero + } + + var hasDisplayedContent: Bool { + return columnRecords.count > 0 || rowRecords.count > 0 + } + + func resetReusableObjects() { + for cell in visibleCells { + cell.removeFromSuperview() + } + for gridline in visibleVerticalGridlines { + gridline.removeFromSuperlayer() + } + for gridline in visibleHorizontalGridlines { + gridline.removeFromSuperlayer() + } + for border in visibleBorders { + border.removeFromSuperview() + } + visibleCells = ReusableCollection() + visibleVerticalGridlines = ReusableCollection() + visibleHorizontalGridlines = ReusableCollection() + visibleBorders = ReusableCollection() + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return gestureRecognizer is UIPanGestureRecognizer + } + + override func touchesShouldBegin(_ touches: Set, with event: UIEvent?, in view: UIView) -> Bool { + return hasDisplayedContent + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard hasDisplayedContent else { + return + } + touchesBegan?(touches, event) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard hasDisplayedContent else { + return + } + touchesEnded?(touches, event) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + guard hasDisplayedContent else { + return + } + touchesCancelled?(touches, event) + } +} diff --git a/Sources/PashaKit/SpreadsheetView/SpreadsheetView+CirclularScrolling.swift b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+CirclularScrolling.swift new file mode 100644 index 0000000..9c2df53 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+CirclularScrolling.swift @@ -0,0 +1,112 @@ +// +// SpreadsheetView+CirclularScrolling.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 5/1/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +extension SpreadsheetView { + func scrollToHorizontalCenter() { + rowHeaderView.state.contentOffset.x = centerOffset.x + tableView.state.contentOffset.x = centerOffset.x + } + + func scrollToVerticalCenter() { + columnHeaderView.state.contentOffset.y = centerOffset.y + tableView.state.contentOffset.y = centerOffset.y + } + + func recenterHorizontallyIfNecessary() { + let currentOffset = tableView.state.contentOffset + let distance = currentOffset.x - centerOffset.x + let threshold = tableView.state.contentSize.width / 4 + if abs(distance) > threshold { + if distance > 0 { + rowHeaderView.state.contentOffset.x = distance + tableView.state.contentOffset.x = distance + } else { + let offset = centerOffset.x + (centerOffset.x - threshold) + rowHeaderView.state.contentOffset.x = offset + tableView.state.contentOffset.x = offset + } + } + } + + func recenterVerticallyIfNecessary() { + let currentOffset = tableView.state.contentOffset + let distance = currentOffset.y - centerOffset.y + let threshold = tableView.state.contentSize.height / 4 + if abs(distance) > threshold { + if distance > 0 { + columnHeaderView.state.contentOffset.y = distance + tableView.state.contentOffset.y = distance + } else { + let offset = centerOffset.y + (centerOffset.y - threshold) + columnHeaderView.state.contentOffset.y = offset + tableView.state.contentOffset.y = offset + } + } + } + + func determineCircularScrollScalingFactor() -> (horizontal: Int, vertical: Int) { + return (determineHorizontalCircularScrollScalingFactor(), determineVerticalCircularScrollScalingFactor()) + } + + func determineHorizontalCircularScrollScalingFactor() -> Int { + guard circularScrollingOptions.direction.contains(.horizontally) else { + return 1 + } + let tableContentWidth = layoutProperties.columnWidth - layoutProperties.frozenColumnWidth + let tableWidth = frame.width - layoutProperties.frozenColumnWidth + var scalingFactor = 3 + while tableContentWidth > 0 && Int(tableContentWidth) * scalingFactor < Int(tableWidth) * 3 { + scalingFactor += 3 + } + return scalingFactor + } + + func determineVerticalCircularScrollScalingFactor() -> Int { + guard circularScrollingOptions.direction.contains(.vertically) else { + return 1 + } + let tableContentHeight = layoutProperties.rowHeight - layoutProperties.frozenRowHeight + let tableHeight = frame.height - layoutProperties.frozenRowHeight + var scalingFactor = 3 + while tableContentHeight > 0 && Int(tableContentHeight) * scalingFactor < Int(tableHeight) * 3 { + scalingFactor += 3 + } + return scalingFactor + } + + func calculateCenterOffset() -> CGPoint { + var centerOffset = CGPoint.zero + if circularScrollingOptions.direction.contains(.horizontally) { + for column in 0.. 0 && numberOfRows > 0 else { + return + } + + if circularScrollingOptions.direction.contains(.horizontally) { + recenterHorizontallyIfNecessary() + } + if circularScrollingOptions.direction.contains(.vertically) { + recenterVerticallyIfNecessary() + } + + layoutCornerView() + layoutRowHeaderView() + layoutColumnHeaderView() + layoutTableView() + } + + private func layout(scrollView: ScrollView) { + let layoutEngine = LayoutEngine(spreadsheetView: self, scrollView: scrollView) + layoutEngine.layout() + } + + private func layoutCornerView() { + guard frozenColumns > 0 && frozenRows > 0 && circularScrolling.options.headerStyle == .none else { + cornerView.isHidden = true + return + } + cornerView.isHidden = false + layout(scrollView: cornerView) + } + + private func layoutColumnHeaderView() { + guard frozenColumns > 0 else { + columnHeaderView.isHidden = true + return + } + columnHeaderView.isHidden = false + layout(scrollView: columnHeaderView) + } + + private func layoutRowHeaderView() { + guard frozenRows > 0 else { + rowHeaderView.isHidden = true + return + } + rowHeaderView.isHidden = false + layout(scrollView: rowHeaderView) + } + + private func layoutTableView() { + layout(scrollView: tableView) + } + + func layoutAttributeForCornerView() -> LayoutAttributes { + return LayoutAttributes(startColumn: 0, + startRow: 0, + numberOfColumns: frozenColumns, + numberOfRows: frozenRows, + columnCount: frozenColumns, + rowCount: frozenRows, + insets: .zero) + } + + func layoutAttributeForColumnHeaderView() -> LayoutAttributes { + let insets = circularScrollingOptions.headerStyle == .columnHeaderStartsFirstRow ? CGPoint(x: 0, y: layoutProperties.rowHeightCache.prefix(upTo: frozenRows).reduce(0) { $0 + $1 } + intercellSpacing.height * CGFloat(layoutProperties.frozenRows)) : .zero + return LayoutAttributes(startColumn: 0, + startRow: layoutProperties.frozenRows, + numberOfColumns: layoutProperties.frozenColumns, + numberOfRows: layoutProperties.numberOfRows, + columnCount: layoutProperties.frozenColumns, + rowCount: layoutProperties.numberOfRows * circularScrollScalingFactor.vertical, + insets: insets) + } + + func layoutAttributeForRowHeaderView() -> LayoutAttributes { + let insets = circularScrollingOptions.headerStyle == .rowHeaderStartsFirstColumn ? CGPoint(x: layoutProperties.columnWidthCache.prefix(upTo: frozenColumns).reduce(0) { $0 + $1 } + intercellSpacing.width * CGFloat(layoutProperties.frozenColumns), y: 0) : .zero + return LayoutAttributes(startColumn: layoutProperties.frozenColumns, + startRow: 0, + numberOfColumns: layoutProperties.numberOfColumns, + numberOfRows: layoutProperties.frozenRows, + columnCount: layoutProperties.numberOfColumns * circularScrollScalingFactor.horizontal, + rowCount: layoutProperties.frozenRows, + insets: insets) + } + + func layoutAttributeForTableView() -> LayoutAttributes { + return LayoutAttributes(startColumn: layoutProperties.frozenColumns, + startRow: layoutProperties.frozenRows, + numberOfColumns: layoutProperties.numberOfColumns, + numberOfRows: layoutProperties.numberOfRows, + columnCount: layoutProperties.numberOfColumns * circularScrollScalingFactor.horizontal, + rowCount: layoutProperties.numberOfRows * circularScrollScalingFactor.vertical, + insets: .zero) + } + + func resetLayoutProperties() -> LayoutProperties { + guard let dataSource = dataSource else { + return LayoutProperties() + } + + let numberOfColumns = dataSource.numberOfColumns(in: self) + let numberOfRows = dataSource.numberOfRows(in: self) + + let frozenColumns = dataSource.frozenColumns(in: self) + let frozenRows = dataSource.frozenRows(in: self) + + guard numberOfColumns >= 0 else { + fatalError("`numberOfColumns(in:)` must return a value greater than or equal to 0") + } + guard numberOfRows >= 0 else { + fatalError("`numberOfRows(in:)` must return a value greater than or equal to 0") + } + guard frozenColumns <= numberOfColumns else { + fatalError("`frozenColumns(in:) must return a value less than or equal to `numberOfColumns(in:)`") + } + guard frozenRows <= numberOfRows else { + fatalError("`frozenRows(in:) must return a value less than or equal to `numberOfRows(in:)`") + } + + let mergedCells = dataSource.mergedCells(in: self) + let mergedCellLayouts: [Location: CellRange] = { () in + var layouts = [Location: CellRange]() + for mergedCell in mergedCells { + if (mergedCell.from.column < frozenColumns && mergedCell.to.column >= frozenColumns) || + (mergedCell.from.row < frozenRows && mergedCell.to.row >= frozenRows) { + fatalError("cannot merge frozen and non-frozen column or rows") + } + for column in mergedCell.from.column...mergedCell.to.column { + for row in mergedCell.from.row...mergedCell.to.row { + guard column < numberOfColumns && row < numberOfRows else { + fatalError("the range of `mergedCell` cannot exceed the total column or row count") + } + let location = Location(row: row, column: column) + if let existingMergedCell = layouts[location] { + if existingMergedCell.contains(mergedCell) { + continue + } + if mergedCell.contains(existingMergedCell) { + layouts[location] = nil + } else { + fatalError("cannot merge cells in a range that overlap existing merged cells") + } + } + mergedCell.size = nil + layouts[location] = mergedCell + } + } + } + return layouts + }() + + var columnWidthCache = [CGFloat]() + var frozenColumnWidth: CGFloat = 0 + for column in 0..= startColumn { + width += layoutProperties.columnWidthCache[index] + intercellSpacing.width + } + } + + let startRow = scrollView.layoutAttributes.startRow + let rowCount = scrollView.layoutAttributes.rowCount + var height: CGFloat = 0 + for row in startRow..= startRow { + height += layoutProperties.rowHeightCache[index] + intercellSpacing.height + } + } + + scrollView.state.contentSize = CGSize(width: width + intercellSpacing.width, height: height + intercellSpacing.height) + } + + func resetScrollViewFrame() { + defer { + cornerView.frame = cornerView.state.frame + columnHeaderView.frame = columnHeaderView.state.frame + rowHeaderView.frame = rowHeaderView.state.frame + tableView.frame = tableView.state.frame + } + + let contentInset: UIEdgeInsets + if #available(iOS 11.0, *) { + contentInset = rootView.adjustedContentInset + } else { + contentInset = rootView.contentInset + } + let horizontalInset = contentInset.left + contentInset.right + let verticalInset = contentInset.top + contentInset.bottom + + cornerView.state.frame = CGRect(origin: .zero, size: cornerView.state.contentSize) + columnHeaderView.state.frame = CGRect(x: 0, y: 0, width: columnHeaderView.state.contentSize.width, height: frame.height) + rowHeaderView.state.frame = CGRect(x: 0, y: 0, width: frame.width, height: rowHeaderView.state.contentSize.height) + tableView.state.frame = CGRect(origin: .zero, size: frame.size) + + if frozenColumns > 0 { + tableView.state.frame.origin.x = columnHeaderView.state.frame.width - intercellSpacing.width + tableView.state.frame.size.width = (frame.width - horizontalInset) - (columnHeaderView.state.frame.width - intercellSpacing.width) + + if circularScrollingOptions.headerStyle != .rowHeaderStartsFirstColumn { + rowHeaderView.state.frame.origin.x = tableView.state.frame.origin.x + rowHeaderView.state.frame.size.width = tableView.state.frame.size.width + } + } else { + tableView.state.frame.size.width = frame.width - horizontalInset + } + if frozenRows > 0 { + tableView.state.frame.origin.y = rowHeaderView.state.frame.height - intercellSpacing.height + tableView.state.frame.size.height = (frame.height - verticalInset) - (rowHeaderView.state.frame.height - intercellSpacing.height) + + if circularScrollingOptions.headerStyle != .columnHeaderStartsFirstRow { + columnHeaderView.state.frame.origin.y = tableView.state.frame.origin.y + columnHeaderView.state.frame.size.height = tableView.state.frame.size.height + } + } else { + tableView.state.frame.size.height = frame.height - verticalInset + } + + resetOverlayViewContentSize(contentInset) + } + + func resetOverlayViewContentSize(_ contentInset: UIEdgeInsets) { + let width = contentInset.left + contentInset.right + tableView.state.frame.origin.x + tableView.state.contentSize.width + let height = contentInset.top + contentInset.bottom + tableView.state.frame.origin.y + tableView.state.contentSize.height + overlayView.contentSize = CGSize(width: width, height: height) + overlayView.contentOffset.x = tableView.state.contentOffset.x - contentInset.left + overlayView.contentOffset.y = tableView.state.contentOffset.y - contentInset.top + } + + func resetScrollViewArrangement() { + tableView.removeFromSuperview() + columnHeaderView.removeFromSuperview() + rowHeaderView.removeFromSuperview() + cornerView.removeFromSuperview() + if circularScrollingOptions.headerStyle == .columnHeaderStartsFirstRow { + rootView.addSubview(tableView) + rootView.addSubview(rowHeaderView) + rootView.addSubview(columnHeaderView) + rootView.addSubview(cornerView) + } else { + rootView.addSubview(tableView) + rootView.addSubview(columnHeaderView) + rootView.addSubview(rowHeaderView) + rootView.addSubview(cornerView) + } + } + + func findIndex(in records: [CGFloat], for offset: CGFloat) -> Int { + let index = records.insertionIndex(of: offset) + return index == 0 ? 0 : index - 1 + } +} diff --git a/Sources/PashaKit/SpreadsheetView/SpreadsheetView+Touches.swift b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+Touches.swift new file mode 100644 index 0000000..50534a1 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+Touches.swift @@ -0,0 +1,133 @@ +// +// SpreadsheetView+Touches.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 5/1/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +extension SpreadsheetView { + func touchesBegan(_ touches: Set, _ event: UIEvent?) { + guard currentTouch == nil else { + return + } + currentTouch = touches.first + + unhighlightAllItems() + highlightItems(on: touches) + if !allowsMultipleSelection, + let touch = touches.first, let indexPath = indexPathForItem(at: touch.location(in: self)), + let cell = cellForItem(at: indexPath), cell.isUserInteractionEnabled { + selectedIndexPaths.forEach { + cellsForItem(at: $0).forEach { $0.isSelected = false } + } + } + } + + func touchesEnded(_ touches: Set, _ event: UIEvent?) { + guard let touch = touches.first, touch === currentTouch else { + return + } + + let highlightedItems = highlightedIndexPaths + unhighlightAllItems() + if allowsMultipleSelection, + let touch = touches.first, let indexPath = indexPathForItem(at: touch.location(in: self)), + selectedIndexPaths.contains(indexPath) { + if delegate?.spreadsheetView(self, shouldDeselectItemAt: indexPath) ?? true { + deselectItem(at: indexPath) + } + } else { + selectItems(on: touches, highlightedItems: highlightedItems) + } + + clearCurrentTouch() + } + + func touchesCancelled(_ touches: Set, _ event: UIEvent?) { + unhighlightAllItems() + restorePreviousSelection() + clearCurrentTouch() + } + + func highlightItems(on touches: Set) { + guard allowsSelection else { + return + } + if let touch = touches.first { + if let indexPath = indexPathForItem(at: touch.location(in: self)) { + guard let cell = cellForItem(at: indexPath), cell.isUserInteractionEnabled else { + return + } + if delegate?.spreadsheetView(self, shouldHighlightItemAt: indexPath) ?? true { + highlightedIndexPaths.insert(indexPath) + cellsForItem(at: indexPath).forEach { + $0.isHighlighted = true + } + delegate?.spreadsheetView(self, didHighlightItemAt: indexPath) + } + } + } + } + + private func unhighlightAllItems() { + highlightedIndexPaths.forEach { (indexPath) in + cellsForItem(at: indexPath).forEach { + $0.isHighlighted = false + } + delegate?.spreadsheetView(self, didUnhighlightItemAt: indexPath) + } + highlightedIndexPaths.removeAll() + } + + private func selectItems(on touches: Set, highlightedItems: Set) { + guard allowsSelection else { + return + } + if let touch = touches.first { + if let indexPath = indexPathForItem(at: touch.location(in: self)), highlightedItems.contains(indexPath) { + selectItem(at: indexPath) + } + } + } + + private func selectItem(at indexPath: IndexPath) { + let cells = cellsForItem(at: indexPath) + if !cells.isEmpty && delegate?.spreadsheetView(self, shouldSelectItemAt: indexPath) ?? true { + if !allowsMultipleSelection { + selectedIndexPaths.remove(indexPath) + deselectAllItems() + } + cells.forEach { + $0.isSelected = true + } + delegate?.spreadsheetView(self, didSelectItemAt: indexPath) + selectedIndexPaths.insert(indexPath) + } + } + + private func deselectItem(at indexPath: IndexPath) { + let cells = cellsForItem(at: indexPath) + cells.forEach { + $0.isSelected = false + } + delegate?.spreadsheetView(self, didDeselectItemAt: indexPath) + selectedIndexPaths.remove(indexPath) + } + + private func deselectAllItems() { + selectedIndexPaths.forEach { deselectItem(at: $0) } + } + + @objc func restorePreviousSelection() { + selectedIndexPaths.forEach { + cellsForItem(at: $0).forEach { $0.isSelected = true } + } + } + + @objc func clearCurrentTouch() { + currentTouch = nil + } +} diff --git a/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIScrollView.swift b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIScrollView.swift new file mode 100644 index 0000000..b50118c --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIScrollView.swift @@ -0,0 +1,88 @@ +// +// SpreadsheetView+UIScrollView.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 5/1/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +extension SpreadsheetView { + public var contentOffset: CGPoint { + get { + return tableView.contentOffset + } + set { + tableView.contentOffset = newValue + } + } + + public var scrollIndicatorInsets: UIEdgeInsets { + get { + return overlayView.scrollIndicatorInsets + } + set { + overlayView.scrollIndicatorInsets = newValue + } + } + + public var contentSize: CGSize { + get { + return overlayView.contentSize + } + } + + public var contentInset: UIEdgeInsets { + get { + return rootView.contentInset + } + set { + rootView.contentInset = newValue + overlayView.contentInset = newValue + } + } + + @available(iOS 11.0, *) + public var adjustedContentInset: UIEdgeInsets { + get { + return rootView.adjustedContentInset + } + } + + public func flashScrollIndicators() { + overlayView.flashScrollIndicators() + } + + public func setContentOffset(_ contentOffset: CGPoint, animated: Bool) { + tableView.setContentOffset(contentOffset, animated: animated) + } + + public func scrollRectToVisible(_ rect: CGRect, animated: Bool) { + tableView.scrollRectToVisible(rect, animated: animated) + } + + func _notifyDidScroll() { + resetScrollViewFrame() + } + + public override func isKind(of aClass: AnyClass) -> Bool { + if #available(iOS 11.0, *) { + return super.isKind(of: aClass) + } else { + return rootView.isKind(of: aClass) + } + } + + public override func forwardingTarget(for aSelector: Selector!) -> Any? { + if #available(iOS 11.0, *) { + return super.forwardingTarget(for: aSelector) + } else { + if overlayView.responds(to: aSelector) { + return overlayView + } else { + return super.forwardingTarget(for: aSelector) + } + } + } +} diff --git a/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIScrollViewDelegate.swift b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIScrollViewDelegate.swift new file mode 100644 index 0000000..b1d19d0 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIScrollViewDelegate.swift @@ -0,0 +1,58 @@ +// +// SpreadsheetView+UIScrollViewDelegate.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 5/1/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +extension SpreadsheetView: UIScrollViewDelegate { + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + rowHeaderView.delegate = nil + columnHeaderView.delegate = nil + tableView.delegate = nil + defer { + rowHeaderView.delegate = self + columnHeaderView.delegate = self + tableView.delegate = self + } + + if tableView.contentOffset.x < 0 && !stickyColumnHeader { + let offset = tableView.contentOffset.x * -1 + cornerView.frame.origin.x = offset + columnHeaderView.frame.origin.x = offset + } else { + cornerView.frame.origin.x = 0 + columnHeaderView.frame.origin.x = 0 + } + if tableView.contentOffset.y < 0 && !stickyRowHeader { + let offset = tableView.contentOffset.y * -1 + cornerView.frame.origin.y = offset + rowHeaderView.frame.origin.y = offset + } else { + cornerView.frame.origin.y = 0 + rowHeaderView.frame.origin.y = 0 + } + + rowHeaderView.contentOffset.x = tableView.contentOffset.x + columnHeaderView.contentOffset.y = tableView.contentOffset.y + + setNeedsLayout() + } + + public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + guard let indexPath = pendingSelectionIndexPath else { + return + } + cellsForItem(at: indexPath).forEach { $0.setSelected(true, animated: true) } + delegate?.spreadsheetView(self, didSelectItemAt: indexPath) + pendingSelectionIndexPath = nil + } + + @available(iOS 11.0, *) + public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { + resetScrollViewFrame() + } +} diff --git a/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UISnapshotting.swift b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UISnapshotting.swift new file mode 100644 index 0000000..de9457b --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UISnapshotting.swift @@ -0,0 +1,35 @@ +// +// SpreadsheetView+UISnapshotting.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 2017/06/03. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +extension SpreadsheetView { + public override func resizableSnapshotView(from rect: CGRect, afterScreenUpdates afterUpdates: Bool, withCapInsets capInsets: UIEdgeInsets) -> UIView? { + if cornerView.frame.intersects(cornerView.convert(rect, to: self)) { + return cornerView.resizableSnapshotView(from: rect.offsetBy(dx: -cornerView.frame.origin.x, dy: -cornerView.frame.origin.y), + afterScreenUpdates: afterUpdates, + withCapInsets: capInsets) + } + if columnHeaderView.frame.intersects(columnHeaderView.convert(rect, to: self)) { + return columnHeaderView.resizableSnapshotView(from: rect.offsetBy(dx: -columnHeaderView.frame.origin.x, dy: -columnHeaderView.frame.origin.y), + afterScreenUpdates: afterUpdates, + withCapInsets: capInsets) + } + if rowHeaderView.frame.intersects(rowHeaderView.convert(rect, to: self)) { + return rowHeaderView.resizableSnapshotView(from: rect.offsetBy(dx: -rowHeaderView.frame.origin.x, dy: -rowHeaderView.frame.origin.y), + afterScreenUpdates: afterUpdates, + withCapInsets: capInsets) + } + if tableView.frame.intersects(tableView.convert(rect, to: self)) { + return tableView.resizableSnapshotView(from: rect.offsetBy(dx: -tableView.frame.origin.x, dy: -tableView.frame.origin.y), + afterScreenUpdates: afterUpdates, + withCapInsets: capInsets) + } + return super.resizableSnapshotView(from: rect, afterScreenUpdates: afterUpdates, withCapInsets: capInsets) + } +} diff --git a/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIViewHierarchy.swift b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIViewHierarchy.swift new file mode 100644 index 0000000..58ef0b0 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/SpreadsheetView+UIViewHierarchy.swift @@ -0,0 +1,39 @@ +// +// SpreadsheetView+UIViewHierarchy.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 5/19/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +extension SpreadsheetView { + public override func insertSubview(_ view: UIView, at index: Int) { + overlayView.insertSubview(view, at: index) + } + + public override func exchangeSubview(at index1: Int, withSubviewAt index2: Int) { + overlayView.exchangeSubview(at: index1, withSubviewAt: index2) + } + + public override func addSubview(_ view: UIView) { + overlayView.addSubview(view) + } + + public override func insertSubview(_ view: UIView, belowSubview siblingSubview: UIView) { + overlayView.insertSubview(view, belowSubview: siblingSubview) + } + + public override func insertSubview(_ view: UIView, aboveSubview siblingSubview: UIView) { + overlayView.insertSubview(view, aboveSubview: siblingSubview) + } + + public override func bringSubviewToFront(_ view: UIView) { + overlayView.bringSubviewToFront(view) + } + + public override func sendSubviewToBack(_ view: UIView) { + overlayView.sendSubviewToBack(view) + } +} diff --git a/Sources/PashaKit/SpreadsheetView/SpreadsheetView.swift b/Sources/PashaKit/SpreadsheetView/SpreadsheetView.swift new file mode 100644 index 0000000..25f95f7 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/SpreadsheetView.swift @@ -0,0 +1,808 @@ +// +// SpreadsheetView.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 3/16/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +public class SpreadsheetView: UIView { + /// The object that provides the data for the collection view. + /// + /// - Note: The data source must adopt the `SpreadsheetViewDataSource` protocol. + /// The spreadsheet view maintains a weak reference to the data source object. + public weak var dataSource: SpreadsheetViewDataSource? { + didSet { + resetTouchHandlers(to: [tableView, columnHeaderView, rowHeaderView, cornerView]) + setNeedsReload() + } + } + /// The object that acts as the delegate of the spreadsheet view. + /// - Note: The delegate must adopt the `SpreadsheetViewDelegate` protocol. + /// The spreadsheet view maintains a weak reference to the delegate object. + /// + /// The delegate object is responsible for managing selection behavior and interactions with individual items. + public weak var delegate: SpreadsheetViewDelegate? + + /// The horizontal and vertical spacing between cells. + /// + /// - Note: The default spacing is `(1.0, 1.0)`. Negative values are not supported. + public var intercellSpacing = CGSize(width: 1, height: 1) + public var gridStyle: GridStyle = .solid(width: 1, color: .lightGray) + + /// A Boolean value that indicates whether users can select cells in the spreadsheet view. + /// + /// - Note: If the value of this property is `true` (the default), users can select cells. + /// If you want more fine-grained control over the selection of cells, + /// you must provide a delegate object and implement the appropriate methods of the `SpreadsheetViewDelegate` protocol. + /// + /// - SeeAlso: `allowsMultipleSelection` + public var allowsSelection = true { + didSet { + if !allowsSelection { + allowsMultipleSelection = false + } + } + } + /// A Boolean value that determines whether users can select more than one cell in the spreadsheet view. + /// + /// - Note: This property controls whether multiple cells can be selected simultaneously. + /// The default value of this property is `false`. + /// + /// When the value of this property is true, tapping a cell adds it to the current selection (assuming the delegate permits the cell to be selected). + /// Tapping the cell again removes it from the selection. + /// + /// - SeeAlso: `allowsSelection` + public var allowsMultipleSelection = false { + didSet { + if allowsMultipleSelection { + allowsSelection = true + } + } + } + + /// A Boolean value that controls whether the vertical scroll indicator is visible. + /// + /// The default value is `true`. The indicator is visible while tracking is underway and fades out after tracking. + public var showsVerticalScrollIndicator = true { + didSet { + overlayView.showsVerticalScrollIndicator = showsVerticalScrollIndicator + } + } + /// A Boolean value that controls whether the horizontal scroll indicator is visible. + /// + /// The default value is `true`. The indicator is visible while tracking is underway and fades out after tracking. + public var showsHorizontalScrollIndicator = true { + didSet { + overlayView.showsHorizontalScrollIndicator = showsHorizontalScrollIndicator + } + } + + /// A Boolean value that controls whether the scroll-to-top gesture is enabled. + /// + /// - Note: The scroll-to-top gesture is a tap on the status bar. When a user makes this gesture, + /// the system asks the scroll view closest to the status bar to scroll to the top. + /// If that scroll view has `scrollsToTop` set to `false`, its delegate returns false from `scrollViewShouldScrollToTop(_:)`, + /// or the content is already at the top, nothing happens. + /// + /// After the scroll view scrolls to the top of the content view, it sends the delegate a `scrollViewDidScrollToTop(_:)` message. + /// + /// The default value of scrollsToTop is `true`. + /// + /// On iPhone, the scroll-to-top gesture has no effect if there is more than one scroll view on-screen that has `scrollsToTop` set to `true`. + public var scrollsToTop: Bool = true { + didSet { + tableView.scrollsToTop = scrollsToTop + } + } + + public var circularScrolling: CircularScrollingConfiguration = CircularScrolling.Configuration.none { + didSet { + circularScrollingOptions = circularScrolling.options + if circularScrollingOptions.direction.contains(.horizontally) { + showsHorizontalScrollIndicator = false + } + if circularScrollingOptions.direction.contains(.vertically) { + showsVerticalScrollIndicator = false + scrollsToTop = false + } + } + } + var circularScrollingOptions = CircularScrolling.Configuration.none.options + var circularScrollScalingFactor: (horizontal: Int, vertical: Int) = (1, 1) + var centerOffset = CGPoint.zero + + /// The view that provides the background appearance. + /// + /// - Note: The view (if any) in this property is positioned underneath all of the other content and sized automatically to fill the entire bounds of the spreadsheet view. + /// The background view does not scroll with the spreadsheet view’s other content. The spreadsheet view maintains a strong reference to the background view object. + /// + /// This property is nil by default, which displays the background color of the spreadsheet view. + public var backgroundView: UIView? { + willSet { + backgroundView?.removeFromSuperview() + } + didSet { + if let backgroundView = backgroundView { + backgroundView.frame = bounds + backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + guard #available(iOS 11.0, *) else { + super.insertSubview(backgroundView, at: 0) + return + } + } + } + } + + @available(iOS 11.0, *) + public override func safeAreaInsetsDidChange() { + if let backgroundView = backgroundView { + backgroundView.removeFromSuperview() + super.insertSubview(backgroundView, at: 0) + } + } + + /// Returns an array of visible cells currently displayed by the spreadsheet view. + /// + /// - Note: This method returns the complete list of visible cells displayed by the collection view. + /// + /// - Returns: An array of `Cell` objects. If no cells are visible, this method returns an empty array. + public var visibleCells: [Cell] { + let cells: [Cell] = Array(columnHeaderView.visibleCells) + Array(rowHeaderView.visibleCells) + + Array(cornerView.visibleCells) + Array(tableView.visibleCells) + return cells.sorted() + } + + + /// An array of the visible items in the collection view. + /// - Note: The value of this property is a sorted array of IndexPath objects, each of which corresponds to a visible cell in the spreadsheet view. + /// If there are no visible items, the value of this property is an empty array. + /// + /// - SeeAlso: `visibleCells` + public var indexPathsForVisibleItems: [IndexPath] { + return visibleCells.map { $0.indexPath } + } + + public var indexPathForSelectedItem: IndexPath? { + return Array(selectedIndexPaths).sorted().first + } + + /// The index paths for the selected items. + /// - Note: The value of this property is an array of IndexPath objects, each of which corresponds to a single selected item. + /// If there are no selected items, the value of this property is nil. + public var indexPathsForSelectedItems: [IndexPath] { + return Array(selectedIndexPaths).sorted() + } + + /// A Boolean value that determines whether scrolling is disabled in a particular direction. + /// - Note: If this property is `false`, scrolling is permitted in both horizontal and vertical directions. + /// If this property is `true` and the user begins dragging in one general direction (horizontally or vertically), the scroll view disables scrolling in the other direction. + /// If the drag direction is diagonal, then scrolling will not be locked and the user can drag in any direction until the drag completes. + /// The default value is `false` + public var isDirectionalLockEnabled = false { + didSet { + tableView.isDirectionalLockEnabled = isDirectionalLockEnabled + } + } + + /// A Boolean value that controls whether the scroll view bounces past the edge of content and back again. + /// - Note: If the value of this property is `true`, the scroll view bounces when it encounters a boundary of the content. + /// Bouncing visually indicates that scrolling has reached an edge of the content. + /// If the value is `false`, scrolling stops immediately at the content boundary without bouncing. + /// The default value is `true`. + /// + /// - SeeAlso: `alwaysBounceHorizontal`, `alwaysBounceVertical` + public var bounces: Bool { + get { + return tableView.bounces + } + set { + tableView.bounces = newValue + } + } + + /// A Boolean value that determines whether bouncing always occurs when vertical scrolling reaches the end of the content. + /// - Note: If this property is set to true and `bounces` is `true`, vertical dragging is allowed even if the content is smaller than the bounds of the scroll view. + /// The default value is `false`. + /// + /// - SeeAlso: `alwaysBounceHorizontal` + public var alwaysBounceVertical: Bool { + get { + return tableView.alwaysBounceVertical + } + set { + tableView.alwaysBounceVertical = newValue + } + } + + /// A Boolean value that determines whether bouncing always occurs when horizontal scrolling reaches the end of the content view. + /// - Note: If this property is set to `true` and `bounces` is `true`, horizontal dragging is allowed even if the content is smaller than the bounds of the scroll view. + /// The default value is `false`. + /// + /// - SeeAlso: `alwaysBounceVertical` + public var alwaysBounceHorizontal: Bool { + get { + return tableView.alwaysBounceHorizontal + } + set { + tableView.alwaysBounceHorizontal = newValue + } + } + + /// A Boolean value that determines wheather the row header always sticks to the top. + /// - Note: `bounces` has to be `true` and there has to be at least one `frozenRow`. + /// The default value is `false`. + /// + /// - SeeAlso: `stickyColumnHeader` + public var stickyRowHeader: Bool = false + /// A Boolean value that determines wheather the column header always sticks to the top. + /// - Note: `bounces` has to be `true` and there has to be at least one `frozenColumn`. + /// The default value is `false`. + /// + /// - SeeAlso: `stickyRowHeader` + public var stickyColumnHeader: Bool = false + + /// A Boolean value that determines whether paging is enabled for the scroll view. + /// - Note: If the value of this property is `true`, the scroll view stops on multiples of the scroll view’s bounds when the user scrolls. + /// The default value is false. + public var isPagingEnabled: Bool { + get { + return tableView.isPagingEnabled + } + set { + tableView.isPagingEnabled = newValue + } + } + + /// A Boolean value that determines whether scrolling is enabled. + /// - Note: If the value of this property is `true`, scrolling is enabled, and if it is `false`, scrolling is disabled. The default is `true`. + /// + /// When scrolling is disabled, the scroll view does not accept touch events; it forwards them up the responder chain. + public var isScrollEnabled: Bool { + get { + return tableView.isScrollEnabled + } + set { + tableView.isScrollEnabled = newValue + overlayView.isScrollEnabled = newValue + } + } + + /// The style of the scroll indicators. + /// - Note: The default style is `default`. See `UIScrollViewIndicatorStyle` for descriptions of these constants. + public var indicatorStyle: UIScrollView.IndicatorStyle { + get { + return overlayView.indicatorStyle + } + set { + overlayView.indicatorStyle = newValue + } + } + + /// A floating-point value that determines the rate of deceleration after the user lifts their finger. + /// - Note: Your application can use the `UIScrollViewDecelerationRateNormal` and UIScrollViewDecelerationRateFast` constants as reference points for reasonable deceleration rates. + public var decelerationRate: CGFloat { + get { + return tableView.decelerationRate.rawValue + } + set { + tableView.decelerationRate = UIScrollView.DecelerationRate(rawValue: newValue) + } + } + + public var numberOfColumns: Int { + return layoutProperties.numberOfColumns + } + public var numberOfRows: Int { + return layoutProperties.numberOfRows + } + public var frozenColumns: Int { + return layoutProperties.frozenColumns + } + public var frozenRows: Int { + return layoutProperties.frozenRows + } + public var mergedCells: [CellRange] { + return layoutProperties.mergedCells + } + + public var scrollView: UIScrollView { + return overlayView + } + + var layoutProperties = LayoutProperties() + + let rootView = UIScrollView() + let overlayView = UIScrollView() + + let columnHeaderView = ScrollView() + let rowHeaderView = ScrollView() + let cornerView = ScrollView() + let tableView = ScrollView() + + private var cellClasses = [String: Cell.Type]() + private var cellNibs = [String: UINib]() + var cellReuseQueues = [String: ReuseQueue]() + let blankCellReuseIdentifier = UUID().uuidString + + var horizontalGridlineReuseQueue = ReuseQueue() + var verticalGridlineReuseQueue = ReuseQueue() + var borderReuseQueue = ReuseQueue() + + var highlightedIndexPaths = Set() + var selectedIndexPaths = Set() + var pendingSelectionIndexPath: IndexPath? + var currentTouch: UITouch? + + private var needsReload = true + + public override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + private func setup() { + rootView.frame = bounds + rootView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + rootView.showsHorizontalScrollIndicator = false + rootView.showsVerticalScrollIndicator = false + rootView.delegate = self + super.addSubview(rootView) + + tableView.frame = bounds + tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + tableView.autoresizesSubviews = false + tableView.showsHorizontalScrollIndicator = false + tableView.showsVerticalScrollIndicator = false + tableView.delegate = self + + columnHeaderView.frame = bounds + columnHeaderView.frame.size.width = 0 + columnHeaderView.autoresizingMask = [.flexibleHeight] + columnHeaderView.autoresizesSubviews = false + columnHeaderView.showsHorizontalScrollIndicator = false + columnHeaderView.showsVerticalScrollIndicator = false + columnHeaderView.isHidden = true + columnHeaderView.delegate = self + + rowHeaderView.frame = bounds + rowHeaderView.frame.size.height = 0 + rowHeaderView.autoresizingMask = [.flexibleWidth] + rowHeaderView.autoresizesSubviews = false + rowHeaderView.showsHorizontalScrollIndicator = false + rowHeaderView.showsVerticalScrollIndicator = false + rowHeaderView.isHidden = true + rowHeaderView.delegate = self + + cornerView.autoresizesSubviews = false + cornerView.isHidden = true + cornerView.delegate = self + + overlayView.frame = bounds + overlayView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + overlayView.autoresizesSubviews = false + overlayView.isUserInteractionEnabled = false + + rootView.addSubview(tableView) + rootView.addSubview(columnHeaderView) + rootView.addSubview(rowHeaderView) + rootView.addSubview(cornerView) + super.addSubview(overlayView) + + [tableView, columnHeaderView, rowHeaderView, cornerView, overlayView].forEach { + addGestureRecognizer($0.panGestureRecognizer) + if #available(iOS 11.0, *) { + $0.contentInsetAdjustmentBehavior = .never + } + } + } + + @objc(registerClass:forCellWithReuseIdentifier:) + public func register(_ cellClass: Cell.Type, forCellWithReuseIdentifier identifier: String) { + cellClasses[identifier] = cellClass + } + + @objc(registerNib:forCellWithReuseIdentifier:) + public func register(_ nib: UINib, forCellWithReuseIdentifier identifier: String) { + cellNibs[identifier] = nib + } + + public func reloadData() { + layoutProperties = resetLayoutProperties() + circularScrollScalingFactor = determineCircularScrollScalingFactor() + centerOffset = calculateCenterOffset() + + cornerView.layoutAttributes = layoutAttributeForCornerView() + columnHeaderView.layoutAttributes = layoutAttributeForColumnHeaderView() + rowHeaderView.layoutAttributes = layoutAttributeForRowHeaderView() + tableView.layoutAttributes = layoutAttributeForTableView() + + cornerView.resetReusableObjects() + columnHeaderView.resetReusableObjects() + rowHeaderView.resetReusableObjects() + tableView.resetReusableObjects() + + resetContentSize(of: cornerView) + resetContentSize(of: columnHeaderView) + resetContentSize(of: rowHeaderView) + resetContentSize(of: tableView) + + resetScrollViewFrame() + resetScrollViewArrangement() + + if circularScrollingOptions.direction.contains(.horizontally) && tableView.contentOffset.x == 0 { + scrollToHorizontalCenter() + } + if circularScrollingOptions.direction.contains(.vertically) && tableView.contentOffset.y == 0 { + scrollToVerticalCenter() + } + + needsReload = false + setNeedsLayout() + } + + func reloadDataIfNeeded() { + if needsReload { + reloadData() + } + } + + private func setNeedsReload() { + needsReload = true + setNeedsLayout() + } + + public func dequeueReusableCell(withReuseIdentifier identifier: String, for indexPath: IndexPath) -> Cell { + if let reuseQueue = cellReuseQueues[identifier] { + if let cell = reuseQueue.dequeue() { + cell.prepareForReuse() + return cell + } + } else { + let reuseQueue = ReuseQueue() + cellReuseQueues[identifier] = reuseQueue + } + if identifier == blankCellReuseIdentifier { + let cell = BlankCell() + cell.reuseIdentifier = identifier + return cell + } + if let clazz = cellClasses[identifier] { + let cell = clazz.init() + cell.reuseIdentifier = identifier + return cell + } + if let nib = cellNibs[identifier] { + if let cell = nib.instantiate(withOwner: nil, options: nil).first as? Cell { + cell.reuseIdentifier = identifier + return cell + } + } + fatalError("could not dequeue a view with identifier cell - must register a nib or a class for the identifier") + } + + private func resetTouchHandlers(to scrollViews: [ScrollView]) { + scrollViews.forEach { + if let _ = dataSource { + $0.touchesBegan = { [weak self] (touches, event) in + self?.touchesBegan(touches, event) + } + $0.touchesEnded = { [weak self] (touches, event) in + self?.touchesEnded(touches, event) + } + $0.touchesCancelled = { [weak self] (touches, event) in + self?.touchesCancelled(touches, event) + } + } else { + $0.touchesBegan = nil + $0.touchesEnded = nil + $0.touchesCancelled = nil + } + } + } + + public func scrollToItem(at indexPath: IndexPath, at scrollPosition: ScrollPosition, animated: Bool) { + let contentOffset = contentOffsetForScrollingToItem(at: indexPath, at: scrollPosition) + tableView.setContentOffset(contentOffset, animated: animated) + } + + private func contentOffsetForScrollingToItem(at indexPath: IndexPath, at scrollPosition: ScrollPosition) -> CGPoint { + let (column, row) = (indexPath.column, indexPath.row) + guard column < numberOfColumns && row < numberOfRows else { + fatalError("attempt to scroll to invalid index path: {column = \(column), row = \(row)}") + } + + let columnRecords = columnHeaderView.columnRecords + tableView.columnRecords + let rowRecords = rowHeaderView.rowRecords + tableView.rowRecords + var contentOffset = CGPoint(x: columnRecords[column], y: rowRecords[row]) + + let width: CGFloat + let height: CGFloat + if let mergedCell = mergedCell(for: Location(indexPath: indexPath)) { + width = (mergedCell.from.column...mergedCell.to.column).reduce(0) { $0 + layoutProperties.columnWidthCache[$1] } + intercellSpacing.width + height = (mergedCell.from.row...mergedCell.to.row).reduce(0) { $0 + layoutProperties.rowHeightCache[$1] } + intercellSpacing.height + } else { + width = layoutProperties.columnWidthCache[indexPath.column] + height = layoutProperties.rowHeightCache[indexPath.row] + } + + if circularScrollingOptions.direction.contains(.horizontally) { + if contentOffset.x > centerOffset.x { + contentOffset.x -= centerOffset.x + } else { + contentOffset.x += centerOffset.x + } + } + + var horizontalGroupCount = 0 + if scrollPosition.contains(.left) { + horizontalGroupCount += 1 + } + if scrollPosition.contains(.centeredHorizontally) { + horizontalGroupCount += 1 + contentOffset.x = max(tableView.contentOffset.x + (contentOffset.x - (tableView.contentOffset.x + (tableView.frame.width - (width + intercellSpacing.width * 2)) / 2)), 0) + } + if scrollPosition.contains(.right) { + horizontalGroupCount += 1 + contentOffset.x = max(contentOffset.x - tableView.frame.width + width + intercellSpacing.width * 2, 0) + } + + if circularScrollingOptions.direction.contains(.vertically) { + if contentOffset.y > centerOffset.y { + contentOffset.y -= centerOffset.y + } else { + contentOffset.y += centerOffset.y + } + } + + var verticalGroupCount = 0 + if scrollPosition.contains(.top) { + verticalGroupCount += 1 + } + if scrollPosition.contains(.centeredVertically) { + verticalGroupCount += 1 + contentOffset.y = max(tableView.contentOffset.y + contentOffset.y - (tableView.contentOffset.y + (tableView.frame.height - (height + intercellSpacing.height * 2)) / 2), 0) + } + if scrollPosition.contains(.bottom) { + verticalGroupCount += 1 + contentOffset.y = max(contentOffset.y - tableView.frame.height + height + intercellSpacing.height * 2, 0) + } + + let distanceFromRightEdge = tableView.contentSize.width - contentOffset.x + if distanceFromRightEdge < tableView.frame.width { + contentOffset.x -= tableView.frame.width - distanceFromRightEdge + } + let distanceFromBottomEdge = tableView.contentSize.height - contentOffset.y + if distanceFromBottomEdge < tableView.frame.height { + contentOffset.y -= tableView.frame.height - distanceFromBottomEdge + } + + if horizontalGroupCount > 1 { + fatalError("attempt to use a scroll position with multiple horizontal positioning styles") + } + if verticalGroupCount > 1 { + fatalError("attempt to use a scroll position with multiple vertical positioning styles") + } + + if contentOffset.x < 0 { + contentOffset.x = 0 + } + if contentOffset.y < 0 { + contentOffset.y = 0 + } + + return contentOffset + } + + public func selectItem(at indexPath: IndexPath?, animated: Bool, scrollPosition: ScrollPosition) { + guard let indexPath = indexPath else { + deselectAllItems(animated: animated) + return + } + guard allowsSelection else { + return + } + + if !allowsMultipleSelection { + selectedIndexPaths.remove(indexPath) + deselectAllItems(animated: animated) + } + if selectedIndexPaths.insert(indexPath).inserted { + if !scrollPosition.isEmpty { + scrollToItem(at: indexPath, at: scrollPosition, animated: animated) + if animated { + pendingSelectionIndexPath = indexPath + return + } + } + cellsForItem(at: indexPath).forEach { + $0.setSelected(true, animated: animated) + } + } + } + + public func deselectItem(at indexPath: IndexPath, animated: Bool) { + cellsForItem(at: indexPath).forEach { + $0.setSelected(false, animated: animated) + } + selectedIndexPaths.remove(indexPath) + } + + private func deselectAllItems(animated: Bool) { + selectedIndexPaths.forEach { deselectItem(at: $0, animated: animated) } + } + + public func indexPathForItem(at point: CGPoint) -> IndexPath? { + var row = 0 + var column = 0 + if tableView.convert(tableView.bounds, to: self).contains(point), let indexPath = indexPathForItem(at: point, in: tableView) { + (row, column) = (indexPath.row + frozenRows, indexPath.column + frozenColumns) + } else if rowHeaderView.convert(rowHeaderView.bounds, to: self).contains(point), let indexPath = indexPathForItem(at: point, in: rowHeaderView) { + (row, column) = (indexPath.row, indexPath.column + frozenColumns) + } else if columnHeaderView.convert(columnHeaderView.bounds, to: self).contains(point), let indexPath = indexPathForItem(at: point, in: columnHeaderView) { + (row, column) = (indexPath.row + frozenRows, indexPath.column) + } else if cornerView.convert(cornerView.bounds, to: self).contains(point), let indexPath = indexPathForItem(at: point, in: cornerView) { + (row, column) = (indexPath.row, indexPath.column) + } else { + return nil + } + + row = row % numberOfRows + column = column % numberOfColumns + + let location = Location(row: row, column: column) + if let mergedCell = mergedCell(for: location) { + return IndexPath(row: mergedCell.from.row, column: mergedCell.from.column) + } + return IndexPath(row: location.row, column: location.column) + } + + private func indexPathForItem(at location: CGPoint, in scrollView: ScrollView) -> IndexPath? { + let insetX = scrollView.layoutAttributes.insets.x + let insetY = scrollView.layoutAttributes.insets.y + + func isPointInColumn(x: CGFloat, column: Int) -> Bool { + guard column < scrollView.columnRecords.count else { + return false + } + let minX = scrollView.columnRecords[column] + intercellSpacing.width + let maxX = minX + layoutProperties.columnWidthCache[(column + scrollView.layoutAttributes.startColumn) % numberOfColumns] + return x >= minX && x <= maxX + } + func isPointInRow(y: CGFloat, row: Int) -> Bool { + guard row < scrollView.rowRecords.count else { + return false + } + let minY = scrollView.rowRecords[row] + intercellSpacing.height + let maxY = minY + layoutProperties.rowHeightCache[(row + scrollView.layoutAttributes.startRow) % numberOfRows] + return y >= minY && y <= maxY + } + + let point = convert(location, to: scrollView) + let column = findIndex(in: scrollView.columnRecords, for: point.x - insetX) + let row = findIndex(in: scrollView.rowRecords, for: point.y - insetY) + + switch (isPointInColumn(x: point.x - insetX, column: column), isPointInRow(y: point.y, row: row)) { + case (true, true): + return IndexPath(row: row, column: column) + case (true, false): + if isPointInRow(y: point.y - insetY, row: row + 1) { + return IndexPath(row: row + 1, column: column) + } + return nil + case (false, true): + if isPointInColumn(x: point.x - insetX, column: column + 1) { + return IndexPath(row: row, column: column + 1) + } + return nil + case (false, false): + if isPointInColumn(x: point.x - insetX, column: column + 1) && isPointInRow(y: point.y - insetY, row: row + 1) { + return IndexPath(row: row + 1, column: column + 1) + } + return nil + } + } + + public func cellForItem(at indexPath: IndexPath) -> Cell? { + if let cell = tableView.visibleCells.pairs + .filter({ $0.key.row == indexPath.row && $0.key.column == indexPath.column }) + .map({ return $1 }) + .first { + return cell + } + if let cell = rowHeaderView.visibleCells.pairs + .filter({ $0.key.row == indexPath.row && $0.key.column == indexPath.column }) + .map({ return $1 }) + .first { + return cell + } + if let cell = columnHeaderView.visibleCells.pairs + .filter({ $0.key.row == indexPath.row && $0.key.column == indexPath.column }) + .map({ return $1 }) + .first { + return cell + } + if let cell = cornerView.visibleCells.pairs + .filter({ $0.key.row == indexPath.row && $0.key.column == indexPath.column }) + .map({ return $1 }) + .first { + return cell + } + return nil + } + + public func cellsForItem(at indexPath: IndexPath) -> [Cell] { + var cells = [Cell]() + cells.append(contentsOf: + tableView.visibleCells.pairs + .filter { $0.key.row == indexPath.row && $0.key.column == indexPath.column } + .map { return $1 } + ) + cells.append(contentsOf: + rowHeaderView.visibleCells.pairs + .filter { $0.key.row == indexPath.row && $0.key.column == indexPath.column } + .map { return $1 } + ) + cells.append(contentsOf: + columnHeaderView.visibleCells.pairs + .filter { $0.key.row == indexPath.row && $0.key.column == indexPath.column } + .map { return $1 } + ) + cells.append(contentsOf: + cornerView.visibleCells.pairs + .filter { $0.key.row == indexPath.row && $0.key.column == indexPath.column } + .map { return $1 } + ) + return cells + } + + public func rectForItem(at indexPath: IndexPath) -> CGRect { + let (column, row) = (indexPath.column, indexPath.row) + guard column >= 0 && column < numberOfColumns && row >= 0 && row < numberOfRows else { + return .zero + } + + let columnRecords = columnHeaderView.columnRecords + tableView.columnRecords + let rowRecords = rowHeaderView.rowRecords + tableView.rowRecords + + let origin: CGPoint + let size: CGSize + func originFor(column: Int, row: Int) -> CGPoint { + let x = columnRecords[column] + (column >= frozenColumns ? tableView.frame.origin.x : 0) + intercellSpacing.width + let y = rowRecords[row] + (row >= frozenRows ? tableView.frame.origin.y : 0) + intercellSpacing.height + return CGPoint(x: x, y: y) + } + if let mergedCell = mergedCell(for: Location(row: row, column: column)) { + origin = originFor(column: mergedCell.from.column, row: mergedCell.from.row) + + var width: CGFloat = 0 + var height: CGFloat = 0 + for column in mergedCell.from.column...mergedCell.to.column { + width += layoutProperties.columnWidthCache[column] + } + for row in mergedCell.from.row...mergedCell.to.row { + height += layoutProperties.rowHeightCache[row] + } + size = CGSize(width: width + intercellSpacing.width * CGFloat(mergedCell.columnCount - 1), + height: height + intercellSpacing.height * CGFloat(mergedCell.rowCount - 1)) + } else { + origin = originFor(column: column, row: row) + + let width = layoutProperties.columnWidthCache[column] + let height = layoutProperties.rowHeightCache[row] + size = CGSize(width: width, height: height) + } + return CGRect(origin: origin, size: size) + } + + func mergedCell(for indexPath: Location) -> CellRange? { + return layoutProperties.mergedCellLayouts[indexPath] + } +} diff --git a/Sources/PashaKit/SpreadsheetView/SpreadsheetViewDataSource.swift b/Sources/PashaKit/SpreadsheetView/SpreadsheetViewDataSource.swift new file mode 100644 index 0000000..a952c37 --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/SpreadsheetViewDataSource.swift @@ -0,0 +1,71 @@ +// +// SpreadsheetViewDataSource.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 4/21/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +/// Implement this protocol to provide data to an `SpreadsheetView`. +public protocol SpreadsheetViewDataSource: AnyObject { + /// Asks your data source object for the number of columns in the spreadsheet view. + /// + /// - Parameter spreadsheetView: The spreadsheet view requesting this information. + /// - Returns: The number of columns in `spreadsheetView`. + func numberOfColumns(in spreadsheetView: SpreadsheetView) -> Int + /// Asks the number of rows in spreadsheet view. + /// + /// - Parameter spreadsheetView: The spreadsheet view requesting this information. + /// - Returns: The number of rows in `spreadsheetView`. + func numberOfRows(in spreadsheetView: SpreadsheetView) -> Int + + /// Asks the data source for the width to use for a row in a specified location. + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view requesting this information. + /// - column: The index of the column. + /// - Returns: A nonnegative floating-point value that specifies the width (in points) that column should be. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, widthForColumn column: Int) -> CGFloat + /// Asks the data source for the height to use for a row in a specified location. + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view requesting this information. + /// - row: The index of the row. + /// - Returns: A nonnegative floating-point value that specifies the height (in points) that row should be. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, heightForRow row: Int) -> CGFloat + + /// Asks your data source object for the view that corresponds to the specified cell in the spreadsheetView. + /// The cell that is returned must be retrieved from a call to `dequeueReusableCell(withReuseIdentifier:for:)` + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view requesting this information. + /// - indexPath: The location of the cell + /// - Returns: A cell object to be displayed at the location. + /// If you return nil from this method, the blank cell will be displayed by default. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, cellForItemAt indexPath: IndexPath) -> Cell? + + /// Asks your data source object for the array of cell ranges that indicate the range of merged cells in the spreadsheetView. + /// + /// - Parameter spreadsheetView: The spreadsheet view requesting this information. + /// - Returns: An array of the cell ranges indicating the range of merged cells. + func mergedCells(in spreadsheetView: SpreadsheetView) -> [CellRange] + /// Asks your data source object for the number of columns to be frozen as a fixed column header in the spreadsheetView. + /// + /// - Parameter spreadsheetView: The spreadsheet view requesting this information. + /// - Returns: The number of columns to be frozen + func frozenColumns(in spreadsheetView: SpreadsheetView) -> Int + /// Asks your data source object for the number of rows to be frozen as a fixed row header in the spreadsheetView. + /// + /// - Parameter spreadsheetView: The spreadsheet view requesting this information. + /// - Returns: The number of rows to be frozen + func frozenRows(in spreadsheetView: SpreadsheetView) -> Int +} + +extension SpreadsheetViewDataSource { + public func spreadsheetView(_ spreadsheetView: SpreadsheetView, cellForItemAt indexPath: IndexPath) -> Cell? { return nil } + public func mergedCells(in spreadsheetView: SpreadsheetView) -> [CellRange] { return [] } + public func frozenColumns(in spreadsheetView: SpreadsheetView) -> Int { return 0 } + public func frozenRows(in spreadsheetView: SpreadsheetView) -> Int { return 0 } +} diff --git a/Sources/PashaKit/SpreadsheetView/SpreadsheetViewDelegate.swift b/Sources/PashaKit/SpreadsheetView/SpreadsheetViewDelegate.swift new file mode 100644 index 0000000..b23a39d --- /dev/null +++ b/Sources/PashaKit/SpreadsheetView/SpreadsheetViewDelegate.swift @@ -0,0 +1,93 @@ +// +// SpreadsheetViewDelegate.swift +// SpreadsheetView +// +// Created by Kishikawa Katsumi on 4/21/17. +// Copyright © 2017 Kishikawa Katsumi. All rights reserved. +// + +import UIKit + +/// The `SpreadsheetViewDelegate` protocol defines methods that allow you to manage the selection and +/// highlighting of cells in a spreadsheet view and to perform actions on those cells. +/// The methods of this protocol are all optional. +public protocol SpreadsheetViewDelegate: AnyObject { + /// Asks the delegate if the cell should be highlighted during tracking. + /// - Note: As touch events arrive, the spreadsheet view highlights cells in anticipation of the user selecting them. + /// As it processes those touch events, the collection view calls this method to ask your delegate if a given cell should be highlighted. + /// It calls this method only in response to user interactions and does not call it if you programmatically set the highlighting on a cell. + /// + /// If you do not implement this method, the default return value is `true`. + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view object that is asking about the highlight change. + /// - indexPath: The index path of the cell to be highlighted. + /// - Returns: `true` if the item should be highlighted or `false` if it should not. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, shouldHighlightItemAt indexPath: IndexPath) -> Bool + /// Tells the delegate that the cell at the specified index path was highlighted. + /// - Note: The spreadsheet view calls this method only in response to user interactions and does not call it + /// if you programmatically set the highlighting on a cell. + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view object that is notifying you of the highlight change. + /// - indexPath: The index path of the cell that was highlighted. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, didHighlightItemAt indexPath: IndexPath) + /// Tells the delegate that the highlight was removed from the cell at the specified index path. + /// - Note: The spreadsheet view calls this method only in response to user interactions and does not call it + /// if you programmatically change the highlighting on a cell. + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view object that is notifying you of the highlight change. + /// - indexPath: The index path of the cell that had its highlight removed. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, didUnhighlightItemAt indexPath: IndexPath) + /// Asks the delegate if the specified cell should be selected. + /// - Note: The spreadsheet view calls this method when the user tries to select an item in the collection view. + /// It does not call this method when you programmatically set the selection. + /// + /// If you do not implement this method, the default return value is `true`. + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view object that is asking whether the selection should change. + /// - indexPath: The index path of the cell to be selected. + /// - Returns: `true` if the item should be selected or `false` if it should not. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, shouldSelectItemAt indexPath: IndexPath) -> Bool + /// Asks the delegate if the specified item should be deselected. + /// - Note: The spreadsheet view calls this method when the user tries to deselect a cell in the spreadsheet view. + /// It does not call this method when you programmatically deselect items. + /// + /// If you do not implement this method, the default return value is `true`. + /// + /// This method is called when the user taps on an already-selected item in multi-select mode + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view object that is asking whether the selection should change. + /// - indexPath: The index path of the cell to be deselected. + /// - Returns: `true` if the cell should be deselected or `false` if it should not. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, shouldDeselectItemAt indexPath: IndexPath) -> Bool + /// Tells the delegate that the cell at the specified index path was selected. + /// - Note: The spreadsheet view calls this method when the user successfully selects a cell in the spreadsheet view. + /// It does not call this method when you programmatically set the selection. + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view object that is notifying you of the selection change. + /// - indexPath: The index path of the cell that was selected. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, didSelectItemAt indexPath: IndexPath) + /// Tells the delegate that the cell at the specified path was deselected. + /// - Note: The spreadsheet view calls this method when the user successfully deselects an item in the spreadsheet view. + /// It does not call this method when you programmatically deselect items. + /// + /// - Parameters: + /// - spreadsheetView: The spreadsheet view object that is notifying you of the selection change. + /// - indexPath: The index path of the cell that was deselected. + func spreadsheetView(_ spreadsheetView: SpreadsheetView, didDeselectItemAt indexPath: IndexPath) +} + +extension SpreadsheetViewDelegate { + public func spreadsheetView(_ spreadsheetView: SpreadsheetView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { return true } + public func spreadsheetView(_ spreadsheetView: SpreadsheetView, didHighlightItemAt indexPath: IndexPath) {} + public func spreadsheetView(_ spreadsheetView: SpreadsheetView, didUnhighlightItemAt indexPath: IndexPath) {} + public func spreadsheetView(_ spreadsheetView: SpreadsheetView, shouldSelectItemAt indexPath: IndexPath) -> Bool { return true } + public func spreadsheetView(_ spreadsheetView: SpreadsheetView, shouldDeselectItemAt indexPath: IndexPath) -> Bool { return true } + public func spreadsheetView(_ spreadsheetView: SpreadsheetView, didSelectItemAt indexPath: IndexPath) {} + public func spreadsheetView(_ spreadsheetView: SpreadsheetView, didDeselectItemAt indexPath: IndexPath) {} +}