Skip to content

Instantly share code, notes, and snippets.

@christianselig
Created August 1, 2023 15:28
Show Gist options
  • Save christianselig/9b54584cc7f8ffaddfb90376260e5970 to your computer and use it in GitHub Desktop.
Save christianselig/9b54584cc7f8ffaddfb90376260e5970 to your computer and use it in GitHub Desktop.
Apollo Markdown table implementation
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
}
}
}
@christianselig
Copy link
Author

@kdegeek Haha just sharing some code with a friend who wondered how Apollo's tables worked

@kdegeek
Copy link

kdegeek commented Aug 11, 2023

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