Created
August 1, 2023 15:28
-
-
Save christianselig/9b54584cc7f8ffaddfb90376260e5970 to your computer and use it in GitHub Desktop.
Apollo Markdown table implementation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
mutating func visitTable(_ table: Table) -> NSAttributedString { | |
let result = NSMutableAttributedString() | |
for child in table.children { | |
result.append(visit(child)) | |
} | |
if table.hasValidSuccessor(parseTablesInline: parseTablesInline) { | |
result.append(.doubleNewline(withFontSize: FontManager.shared.regularFont().pointSize)) | |
} | |
return result | |
} | |
mutating func visitTableRow(_ tableRow: Table.Row) -> NSAttributedString { | |
let result = NSMutableAttributedString() | |
for child in tableRow.children { | |
result.append(visit(child)) | |
} | |
if tableRow.hasValidSuccessor(parseTablesInline: parseTablesInline) { | |
result.append(.singleNewline(withFontSize: FontManager.shared.regularFont().pointSize)) | |
} | |
return result | |
} | |
mutating func visitTableHead(_ tableHead: Table.Head) -> NSAttributedString { | |
let result = NSMutableAttributedString() | |
for child in tableHead.children { | |
result.append(visit(child)) | |
} | |
result.applyStrong() | |
if tableHead.hasValidSuccessor(parseTablesInline: parseTablesInline) { | |
result.append(.singleNewline(withFontSize: FontManager.shared.regularFont().pointSize)) | |
} | |
return result | |
} | |
mutating func visitTableCell(_ tableCell: Table.Cell) -> NSAttributedString { | |
let result = NSMutableAttributedString() | |
for child in tableCell.children { | |
result.append(visit(child)) | |
} | |
if parseTablesInline && tableCell.hasValidSuccessor(parseTablesInline: parseTablesInline) { | |
result.append(NSAttributedString(string: " | ", attributes: [.font: FontManager.shared.regularFont(), .foregroundColor: ThemeManager.shared.currentTheme.textColor(for: .medium, isRead: false)])) | |
} | |
return result | |
} | |
/// Returns true if this element has sibling elements (that are not Tables) after it, unless parseTablesInline is true in which case any successor will pass. | |
/// e.g.: next element is Table -> false. Next element is Paragraph -> true. Next element does not exist -> false (unless parseTablesInline is true) | |
func hasValidSuccessor(parseTablesInline: Bool) -> Bool { | |
guard let parent = parent else { return false } | |
let childCount = parent.childCount | |
if indexInParent == childCount - 1 { | |
// Is the last element, so successors aren't possible | |
return false | |
} else { | |
if parseTablesInline { | |
return true | |
} else if parent.child(at: indexInParent + 1) is Table { | |
return false | |
} else { | |
return true | |
} | |
} | |
} | |
extension NSAttributedString { | |
static func oneThirdNewline(withFontSize fontSize: CGFloat) -> NSAttributedString { | |
// To get 0.5 newlines, use 1 newlines but at 50% of the size | |
let fontSizeToUse = (fontSize * 0.333).rounded() | |
return NSAttributedString(string: "\n", attributes: [.font: UIFont.systemFont(ofSize: fontSizeToUse, weight: .regular)]) | |
} | |
static func singleNewline(withFontSize fontSize: CGFloat) -> NSAttributedString { | |
return NSAttributedString(string: "\n", attributes: [.font: UIFont.systemFont(ofSize: fontSize, weight: .regular)]) | |
} | |
static func doubleNewline(withFontSize fontSize: CGFloat) -> NSAttributedString { | |
return NSAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: fontSize, weight: .regular)]) | |
} | |
static func twoPointFiveNewlines(withFontSize fontSize: CGFloat) -> NSAttributedString { | |
// To get 1.5 newlines, use three newlines but at 75% of the size | |
let fontSizeToUse = (fontSize * 0.75).rounded() | |
return NSAttributedString(string: "\n\n\n", attributes: [.font: UIFont.systemFont(ofSize: fontSizeToUse, weight: .regular)]) | |
} | |
} | |
class MarkdownTableNode: ASDisplayNode { | |
let table: Table | |
/// 2D array organized as the outer-array being the row, and then the inner array being each item (column) in the row | |
let rowNodes: [MarkdownTableRowNode] | |
var constrainedParentSize: CGSize? | |
init(table: Table) { | |
self.table = table | |
var rowNodes: [MarkdownTableRowNode] = [] | |
// Header row | |
var headRowNodes: [MarkdownTableCellNode] = [] | |
for (i, cell) in table.head.children.enumerated() { | |
let cell = cell as! Table.Cell | |
var compiler = RedditMarkdownCompiler() | |
compiler.isTableHeader = true | |
if let alignment = table.columnAlignments[i] { | |
compiler.forceTextAlignment = alignment.toTextAlignment | |
} | |
let attributedString = compiler.attributedString(from: cell) | |
let cellNode = MarkdownTableCellNode(attributedString: attributedString) | |
headRowNodes.append(cellNode) | |
} | |
let headRowNode = MarkdownTableRowNode(cellNodes: headRowNodes, includeBottomSeparator: false) | |
rowNodes.append(headRowNode) | |
// Body rows | |
for (i, bodyRow) in table.body.children.enumerated() { | |
let bodyRow = bodyRow as! Table.Row | |
var rowItems: [MarkdownTableCellNode] = [] | |
for (j, cell) in bodyRow.children.enumerated() { | |
let cell = cell as! Table.Cell | |
var compiler = RedditMarkdownCompiler() | |
if let alignment = table.columnAlignments[j] { | |
compiler.forceTextAlignment = alignment.toTextAlignment | |
} | |
let attributedString = compiler.attributedString(from: cell) | |
let cellNode = MarkdownTableCellNode(attributedString: attributedString) | |
rowItems.append(cellNode) | |
} | |
let bodyRowNode = MarkdownTableRowNode(cellNodes: rowItems, includeBottomSeparator: i != table.body.childCount - 1) | |
rowNodes.append(bodyRowNode) | |
} | |
self.rowNodes = rowNodes | |
super.init() | |
automaticallyManagesSubnodes = true | |
clipsToBounds = true | |
cornerRadius = 10.0 | |
// Set background colors | |
for (index, rowNode) in rowNodes.enumerated() { | |
if index == 0 { | |
rowNode.backgroundColor = ThemeManager.shared.currentTheme.markdownTableHeaderBackground | |
} else { | |
rowNode.backgroundColor = index % 2 == 1 ? ThemeManager.shared.currentTheme.backgroundColor(for: .none) : ThemeManager.shared.currentTheme.backgroundColor(for: .small) | |
} | |
} | |
} | |
override func didLoad() { | |
super.didLoad() | |
layer.cornerCurve = .continuous | |
layer.borderColor = ThemeManager.shared.currentTheme.separatorColor.cgColor | |
layer.borderWidth = 0.5 | |
} | |
override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { | |
// We communicate the constrained parent size down so that even though we have virtually infinite width due to the scroll view we can find out the true constrained width we're dealing with to try to shrink it down a bit if we can | |
let columnWidths = calculatedColumnWidths(forRows: rowNodes, constrainedWidth: constrainedParentSize?.width) | |
for rowNode in rowNodes { | |
for (index, child) in rowNode.cellNodes.enumerated() { | |
child.style.width = .init(unit: .points, value: columnWidths[index]) | |
} | |
} | |
let verticalStack = ASStackLayoutSpec(direction: .vertical, spacing: 0.0, justifyContent: .start, alignItems: .stretch, children: rowNodes) | |
return verticalStack | |
} | |
private func calculatedColumnWidths(forRows rowNodes: [MarkdownTableRowNode], constrainedWidth: CGFloat?) -> [CGFloat] { | |
let totalColumns = rowNodes[0].cellNodes.count | |
var columnWidths: [CGFloat] = [] | |
for i in 0 ..< totalColumns { | |
var maxWidth: CGFloat = 0.0 | |
for rowNode in rowNodes { | |
let columnToInspect = rowNode.cellNodes[i] | |
let columnWidth = columnToInspect.calculateLayoutThatFits(ASSizeRange(min: .zero, max: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude))).size.width | |
maxWidth = max(columnWidth, maxWidth) | |
} | |
columnWidths.append(maxWidth) | |
} | |
// Have upper limits and lower limits to prevent super wide or super narrow columns | |
let maxWidth: CGFloat = 175.0 | |
let minWidth: CGFloat = 46.0 | |
columnWidths = columnWidths.map { $0.clamped(to: minWidth ... maxWidth) } | |
if let constrainedWidth = constrainedWidth { | |
let totalColumnsWidth = columnWidths.reduce(0.0, { $0 + $1 }) | |
// For whatever reason Texture likes to increase the 0.5 we set as the separator width to 0.67 or similar, so for ease just consider all separators 1.0 wide | |
let totalSeparatorsWidth = CGFloat(totalColumns - 1) * 1.0 | |
let naiveTotalTableWidth = totalColumnsWidth + totalSeparatorsWidth | |
// Algorithm: If the total width is greater than the constrained width but within this percent of it, find what the average width of each column would be at the *constrained* size. We'll be investigating any column >= this average width. Then, say two columns are greater than this, and we need to shave off 70px, that would be 35px to shave off each off each. What if one of these columns, is, say 80px, and subtracting 35px would bring it to 45px, lower than our min width? That's an edge case that should virtually never happen because the average calculated width should be high enough to disqualify anything that low in virtually every case. There may be a case or two where they are caught, but the algorithm would be considerably more complex so oh well. | |
let exceedingThreshold = 0.15 | |
if naiveTotalTableWidth > constrainedWidth && naiveTotalTableWidth * (1.0 - exceedingThreshold) <= constrainedWidth { | |
let averageColumnWidth = constrainedWidth / CGFloat(totalColumns) | |
let totalColumnsExceedingAverageColumnWidth = columnWidths.filter { $0 >= averageColumnWidth }.count | |
let amountExceededBy = naiveTotalTableWidth - constrainedWidth | |
let amountToReduceEachBy = amountExceededBy / CGFloat(totalColumnsExceedingAverageColumnWidth) | |
// Only shrink them if the resulting items would all be wider than the smallest width, otherwise if we're | |
// uniformly shrinking them down we could end up with comically tiny (or even negative width) columns. | |
// (Yeah reading the above code block I was very wrong) | |
let wouldShrinkTooFar = columnWidths.filter { $0 - amountToReduceEachBy < minWidth }.count > 0 | |
if wouldShrinkTooFar { | |
return columnWidths | |
} else { | |
columnWidths = columnWidths.map { columnWidth in | |
if columnWidth >= averageColumnWidth { | |
return columnWidth - amountToReduceEachBy | |
} else { | |
return columnWidth | |
} | |
} | |
} | |
} | |
} | |
return columnWidths | |
} | |
} | |
class MarkdownTableRowNode: ASDisplayNode { | |
let cellNodes: [MarkdownTableCellNode] | |
let verticalSeparatorNodes: [ASDisplayNode] | |
let bottomSeparatorNode: ASDisplayNode? | |
init(cellNodes: [MarkdownTableCellNode], includeBottomSeparator: Bool) { | |
self.cellNodes = cellNodes | |
var verticalSeparatorNodes: [ASDisplayNode] = [] | |
// We want n - 1 separators | |
for _ in 0 ..< cellNodes.count - 1 { | |
let separatorNode = ASDisplayNode() | |
separatorNode.style.width = .init(unit: .points, value: 0.5) | |
separatorNode.style.height = ASDimensionMakeWithFraction(1.0) | |
separatorNode.backgroundColor = ThemeManager.shared.currentTheme.separatorColor | |
separatorNode.isLayerBacked = true | |
verticalSeparatorNodes.append(separatorNode) | |
} | |
self.verticalSeparatorNodes = verticalSeparatorNodes | |
self.bottomSeparatorNode = includeBottomSeparator ? ASDisplayNode() : nil | |
super.init() | |
bottomSeparatorNode?.isLayerBacked = true | |
bottomSeparatorNode?.backgroundColor = ThemeManager.shared.currentTheme.separatorColor | |
bottomSeparatorNode?.style.height = .init(unit: .points, value: 0.5) | |
automaticallyManagesSubnodes = true | |
} | |
override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { | |
let horizontalStack = ASStackLayoutSpec(direction: .horizontal, spacing: 0.0, justifyContent: .start, alignItems: .stretch, children: cellNodesWithSplicedSeparators()) | |
if let bottomSeparatorNode = bottomSeparatorNode { | |
let relativeSpec = ASRelativeLayoutSpec(horizontalPosition: .start, verticalPosition: .end, sizingOption: .minimumSize, child: bottomSeparatorNode) | |
let overlaySpec = ASOverlayLayoutSpec(child: horizontalStack, overlay: relativeSpec) | |
return overlaySpec | |
} else { | |
return horizontalStack | |
} | |
} | |
func cellNodesWithSplicedSeparators() -> [ASDisplayNode] { | |
var nodes: [ASDisplayNode] = [] | |
for (index, cellNode) in cellNodes.enumerated() { | |
nodes.append(cellNode) | |
if index != cellNodes.count - 1 { | |
nodes.append(verticalSeparatorNodes[index]) | |
} | |
} | |
return nodes | |
} | |
} | |
class MarkdownTableCellNode: ASDisplayNode { | |
let textNode = MarkdownTextNode() | |
init(attributedString: NSAttributedString) { | |
super.init() | |
automaticallyManagesSubnodes = true | |
textNode.attributedText = attributedString | |
textNode.didDisplayNodeContentWithRenderingContext = { [weak self] (context, drawParameters) in | |
guard let attributedString = self?.textNode.attributedText else { return } | |
self?.textNode.drawMarkdown(forAttributedString: attributedString, withContext: context) | |
} | |
} | |
override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { | |
return ASInsetLayoutSpec(insets: UIEdgeInsets(top: 5.0, left: 10.0, bottom: 5.0, right: 10.0), child: textNode) | |
} | |
} | |
extension Table { | |
var hasHead: Bool { | |
return head.childCount > 0 | |
} | |
var hasBody: Bool { | |
return body.childCount > 0 | |
} | |
} | |
extension Table.ColumnAlignment { | |
var toTextAlignment: NSTextAlignment { | |
switch self { | |
case .left: | |
return .left | |
case .right: | |
return .right | |
case .center: | |
return .center | |
} | |
} | |
} |
@kdegeek Haha just sharing some code with a friend who wondered how Apollo's tables worked
My heart all the way skipped a beat Lol
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
👀
Christian right now: Don't mind me! I was just poking around the Apollo codebase for some, totally irrelevant reason. Probably nothing.