Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation
import UIKit

final class AsyncRenderCoordinator {
var blockAsyncRender = false

private let queue: DispatchQueue
private var currentRenderId: UInt = 0

init(queueLabel: String = "com.swmansion.enriched.markdown.render") {
queue = DispatchQueue(label: queueLabel)
}

func scheduleRender(
_ render: @escaping () -> NSAttributedString?,
apply: @escaping (NSAttributedString) -> Void
) {
if blockAsyncRender {
return
}

currentRenderId += 1
let renderId = currentRenderId

queue.async { [weak self] in
guard let result = render() else { return }

DispatchQueue.main.async {
guard let self, renderId == self.currentRenderId else { return }
apply(result)
}
}
}

func invalidate() {
currentRenderId += 1
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,39 @@ import UIKit
public struct EnrichedMarkdownText: View {
private let markdown: String

@Environment(\.markdownStyleConfig) private var styleConfig
@Environment(\.markdownThemeLayers) private var themeLayers
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@StateObject private var renderStore = MarkdownRenderStore()

public init(_ markdown: String) {
self.markdown = markdown
}

private var styleConfig: MarkdownStyleConfig {
let traitCollection = ThemeResolver.traitCollection(
colorScheme: colorScheme,
dynamicTypeSize: dynamicTypeSize
)
return MarkdownStyleConfig.resolve(layers: themeLayers, traitCollection: traitCollection)
}

public var body: some View {
MarkdownTextViewRepresentable(
attributedText: MarkdownRenderer.render(markdown, config: styleConfig)
attributedText: renderStore.attributedText
)
.fixedSize(horizontal: false, vertical: true)
.onAppear {
renderStore.schedule(markdown: markdown, config: styleConfig)
}
.onChange(of: markdown) { newValue in
renderStore.schedule(markdown: newValue, config: styleConfig)
}
.onChange(of: styleConfig) { newValue in
renderStore.schedule(markdown: markdown, config: newValue)
}
.onDisappear {
renderStore.invalidate()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import SwiftUI
import UIKit

@MainActor
final class MarkdownRenderStore: ObservableObject {
@Published private(set) var attributedText = NSAttributedString()

private let coordinator = AsyncRenderCoordinator()

func schedule(
markdown: String,
config: MarkdownStyleConfig,
flags: Md4cFlags = .commonMark
) {
if isBlank(markdown) {
attributedText = NSAttributedString()
return
}

coordinator.scheduleRender {
MarkdownRenderer.render(markdown, config: config, flags: flags)
} apply: { [weak self] result in
self?.attributedText = result
}
}

func invalidate() {
coordinator.invalidate()
}

private func isBlank(_ markdown: String) -> Bool {
markdown.isEmpty || markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import UIKit
import XCTest
@testable import EnrichedMarkdown

final class AsyncRenderCoordinatorTests: XCTestCase {
func testApplyRunsOnMainThread() {
let coordinator = AsyncRenderCoordinator()
let expectation = expectation(description: "apply on main")
var appliedOnMain = false

coordinator.scheduleRender({
NSAttributedString(string: "hello")
}, apply: { _ in
appliedOnMain = Thread.isMainThread
expectation.fulfill()
})

waitForExpectations(timeout: 2)
XCTAssertTrue(appliedOnMain)
}

func testSecondRenderSupersedesFirst() {
let coordinator = AsyncRenderCoordinator()
let firstApply = expectation(description: "first apply")
firstApply.isInverted = true
let secondApply = expectation(description: "second apply")

coordinator.scheduleRender({
Thread.sleep(forTimeInterval: 0.05)
return NSAttributedString(string: "first")
}, apply: { _ in
firstApply.fulfill()
})

coordinator.scheduleRender({
NSAttributedString(string: "second")
}, apply: { _ in
secondApply.fulfill()
})

wait(for: [firstApply, secondApply], timeout: 2, enforceOrder: false)
}

func testInvalidateDropsPendingApply() {
let coordinator = AsyncRenderCoordinator()
let applyExpectation = expectation(description: "apply")
applyExpectation.isInverted = true

coordinator.scheduleRender({
Thread.sleep(forTimeInterval: 0.05)
return NSAttributedString(string: "stale")
}, apply: { _ in
applyExpectation.fulfill()
})

coordinator.invalidate()

waitForExpectations(timeout: 0.2)
}

func testBlockAsyncRenderSkipsDispatch() {
let coordinator = AsyncRenderCoordinator()
coordinator.blockAsyncRender = true
let applyExpectation = expectation(description: "apply")
applyExpectation.isInverted = true

coordinator.scheduleRender({
NSAttributedString(string: "blocked")
}, apply: { _ in
applyExpectation.fulfill()
})

waitForExpectations(timeout: 0.1)
}

func testNilRenderResultSkipsApply() {
let coordinator = AsyncRenderCoordinator()
let applyExpectation = expectation(description: "apply")
applyExpectation.isInverted = true

coordinator.scheduleRender({
nil
}, apply: { _ in
applyExpectation.fulfill()
})

waitForExpectations(timeout: 0.2)
}
}