Skip to content

Commit

Permalink
Async attachments rendering (#225)
Browse files Browse the repository at this point in the history
  • Loading branch information
rajdeep authored Sep 25, 2023
1 parent 9137496 commit 5b31164
Show file tree
Hide file tree
Showing 16 changed files with 335 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ class CommandsExampleViewController: ExamplesBaseViewController {
stackView.translatesAutoresizingMaskIntoConstraints = false

editor.delegate = self
editor.asyncAttachmentRenderingDelegate = self
EditorViewContext.shared.delegate = self

editor.registerProcessor(ListTextProcessor())
Expand Down Expand Up @@ -305,7 +306,7 @@ extension CommandsExampleViewController: EditorViewDelegate {
}

func editor(_ editor: EditorView, didChangeSize currentSize: CGSize, previousSize: CGSize) {
print("Height changed from \(previousSize.height) to \(currentSize.height)")
// print("Height changed from \(previousSize.height) to \(currentSize.height)")
}

func editor(_ editor: EditorView, didTapAtLocation location: CGPoint, characterRange: NSRange?) {
Expand Down Expand Up @@ -461,3 +462,13 @@ class ListFormattingProvider: EditorListFormattingProvider {
return sequenceGenerator.value(at: index)
}
}

extension CommandsExampleViewController: AsyncAttachmentRenderingDelegate {
func shouldRenderAsync(attachment: Proton.Attachment) -> Bool {
attachment is GridViewAttachment
}

func didRenderAttachment(_ attachment: Proton.Attachment, in editor: Proton.EditorView) {
print("Render: \(attachment.id)")
}
}
22 changes: 18 additions & 4 deletions ExampleApp/ExampleApp/Commands/CreateGridViewCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,17 @@ public class CreateGridViewCommand: EditorCommand {
self.delegate = delegate

text.append(NSAttributedString(string: "Text before Grid"))
for i in 1...5 {
text.append(makeGridViewAttachment(id: i, numRows: 5, numColumns: 5).string)
timeEvent(label: "Create")
for i in 1..<25 {
text.append(makeGridViewAttachment(id: i, numRows: 25, numColumns: 5).string)
text.append(NSAttributedString(string: "\ntest middle\n"))
}
text.append(NSAttributedString(string: "Text before Grid"))

text.append(NSAttributedString(string: "Text After Grid"))
}

public func execute(on editor: EditorView) {
timeEvent(label: "Render")
editor.attributedText = text
}

Expand All @@ -52,7 +56,7 @@ public class CreateGridViewCommand: EditorCommand {
for col in 0..<numColumns {
let editorInit = {
let editor = EditorView(allowAutogrowing: false)
editor.attributedText = NSAttributedString(string: "Table: \(id) {\(row), \(col)} Text in cell")
editor.attributedText = NSAttributedString(string: "Table \(id) {\(row), \(col)} Text in cell")
return editor
}
let cell = GridCell(editorInitializer: editorInit, rowSpan: [row], columnSpan: [col], initialHeight: 20)
Expand All @@ -62,4 +66,14 @@ public class CreateGridViewCommand: EditorCommand {

return GridViewAttachment(config: config, cells: cells)
}

func timeEvent(label: String) {
let start = DispatchTime.now()
DispatchQueue.main.async {
let end = DispatchTime.now()
let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds
let timeInterval = Double(nanoTime) / 1_000_000_000
print("\(label): \(timeInterval) seconds")
}
}
}
12 changes: 12 additions & 0 deletions Proton/Proton.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@
1B617A9A283724C900095FEE /* EditorViewContextSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B617A99283724C900095FEE /* EditorViewContextSnapshotTests.swift */; };
1B617AA42839EA2D00095FEE /* AttachmentContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B617AA32839EA2D00095FEE /* AttachmentContent.swift */; };
1B6DE9D923C5940B007F9859 /* EditorCommandSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6DE9D823C5940B007F9859 /* EditorCommandSnapshotTests.swift */; };
1B6FB18A2ABA75E7008CE69E /* MockAsyncAttachmentRenderingDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6FB1892ABA75E7008CE69E /* MockAsyncAttachmentRenderingDelegate.swift */; };
1B7A985723C4828A00C34B14 /* RichTextEditorContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7A985623C4828A00C34B14 /* RichTextEditorContext.swift */; };
1B7A985923C484BC00C34B14 /* RichTextViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7A985823C484BC00C34B14 /* RichTextViewDelegate.swift */; };
1B7C188A2AAEC078005457D9 /* AsyncTaskScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7C18892AAEC078005457D9 /* AsyncTaskScheduler.swift */; };
1B7C188C2AB17621005457D9 /* SynchronizedArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7C188B2AB17621005457D9 /* SynchronizedArray.swift */; };
1B7C76AA2608A489006618AC /* FontTraitToggleCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7C76A92608A489006618AC /* FontTraitToggleCommand.swift */; };
1B7C76AB2608A489006618AC /* BoldCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7C76A72608A489006618AC /* BoldCommand.swift */; };
1B7C76AC2608A489006618AC /* ItalicsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7C76A82608A489006618AC /* ItalicsCommand.swift */; };
Expand Down Expand Up @@ -225,8 +228,11 @@
1B617A99283724C900095FEE /* EditorViewContextSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorViewContextSnapshotTests.swift; sourceTree = "<group>"; };
1B617AA32839EA2D00095FEE /* AttachmentContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContent.swift; sourceTree = "<group>"; };
1B6DE9D823C5940B007F9859 /* EditorCommandSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorCommandSnapshotTests.swift; sourceTree = "<group>"; };
1B6FB1892ABA75E7008CE69E /* MockAsyncAttachmentRenderingDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAsyncAttachmentRenderingDelegate.swift; sourceTree = "<group>"; };
1B7A985623C4828A00C34B14 /* RichTextEditorContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RichTextEditorContext.swift; sourceTree = "<group>"; };
1B7A985823C484BC00C34B14 /* RichTextViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RichTextViewDelegate.swift; sourceTree = "<group>"; };
1B7C18892AAEC078005457D9 /* AsyncTaskScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTaskScheduler.swift; sourceTree = "<group>"; };
1B7C188B2AB17621005457D9 /* SynchronizedArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedArray.swift; sourceTree = "<group>"; };
1B7C76A72608A489006618AC /* BoldCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BoldCommand.swift; sourceTree = "<group>"; };
1B7C76A82608A489006618AC /* ItalicsCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItalicsCommand.swift; sourceTree = "<group>"; };
1B7C76A92608A489006618AC /* FontTraitToggleCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontTraitToggleCommand.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -438,6 +444,7 @@
1B45014E247E5AD9007B3EE9 /* SequenceGenerator.swift */,
1B30A35F2489CE3E00FA1D48 /* ListFormattingProvider.swift */,
1BFDC80E254A9BFC00BD83BD /* ListParser.swift */,
1B7C18892AAEC078005457D9 /* AsyncTaskScheduler.swift */,
);
path = Core;
sourceTree = "<group>";
Expand Down Expand Up @@ -468,6 +475,7 @@
1BBAC3CE23CD5A1B0088A1C8 /* UITextRangeExtensions.swift */,
1B164F4023D2A4AB00A0869A /* String+NSRange.swift */,
1B44165228E5706A00836C71 /* GradientView.swift */,
1B7C188B2AB17621005457D9 /* SynchronizedArray.swift */,
);
path = Helpers;
sourceTree = "<group>";
Expand Down Expand Up @@ -825,6 +833,7 @@
1BFFEF1E23C3366200D2BA35 /* MockAttachmentOffsetProvider.swift */,
1B8BE91E23C71E8A00353B17 /* MockEditorViewDelegate.swift */,
1BD993C323CACCE100563ACB /* MockAttachment.swift */,
1B6FB1892ABA75E7008CE69E /* MockAsyncAttachmentRenderingDelegate.swift */,
);
path = Mocks;
sourceTree = "<group>";
Expand Down Expand Up @@ -999,6 +1008,7 @@
1B45CDD223C00856001EB196 /* TextContainer.swift in Sources */,
1B4B60CA247FC51E002B63CF /* ListCommand.swift in Sources */,
1B7C76AB2608A489006618AC /* BoldCommand.swift in Sources */,
1B7C188C2AB17621005457D9 /* SynchronizedArray.swift in Sources */,
1BBAC3CF23CD5A1B0088A1C8 /* UITextRangeExtensions.swift in Sources */,
1BFDC80F254A9BFC00BD83BD /* ListParser.swift in Sources */,
1B7C76AC2608A489006618AC /* ItalicsCommand.swift in Sources */,
Expand Down Expand Up @@ -1031,6 +1041,7 @@
1B4B60CD247FC6AF002B63CF /* ListTextProcessor.swift in Sources */,
1BFFEF0C23C30D5200D2BA35 /* EditorContentView.swift in Sources */,
1B82570923C5674E0033A0A9 /* EditorCommand.swift in Sources */,
1B7C188A2AAEC078005457D9 /* AsyncTaskScheduler.swift in Sources */,
1B91E7B926AA698A0002DF45 /* StrikethroughCommand.swift in Sources */,
1B82570D23C5686F0033A0A9 /* EditorViewContext.swift in Sources */,
1B975AFF23CD454700EC410C /* RichTextViewContext.swift in Sources */,
Expand Down Expand Up @@ -1089,6 +1100,7 @@
1B9AF9E62851EC2500CD65C4 /* GridViewTests.swift in Sources */,
1B98D5B0286828EC0091C927 /* SnapshotTestCase.swift in Sources */,
1BFDC811254AA11F00BD83BD /* ListParserTests.swift in Sources */,
1B6FB18A2ABA75E7008CE69E /* MockAsyncAttachmentRenderingDelegate.swift in Sources */,
1BD1791E23C589340066DC13 /* EditorCommandExecutorTests.swift in Sources */,
1B82570423C48C350033A0A9 /* MockRichTextViewDelegate.swift in Sources */,
1BBE4EFC248CB15E00D1C788 /* ListCommandTests.swift in Sources */,
Expand Down
16 changes: 15 additions & 1 deletion Proton/Sources/Swift/Attachment/Attachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,14 @@ public protocol AttachmentOffsetProviding: AnyObject {
func offset(for attachment: Attachment, in textContainer: NSTextContainer, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGPoint
}

public protocol AsyncAttachmentRenderingDelegate: AnyObject {
func shouldRenderAsync(attachment: Attachment) -> Bool
func didRenderAttachment(_ attachment: Attachment, in editor: EditorView)
}

/// An attachment can be used as a container for any view object. Based on the `AttachmentSize` provided, the attachment automatically renders itself alongside the text in `EditorView`.
/// `Attachment` also provides helper functions like `deleteFromContainer` and `rangeInContainer`
open class Attachment: NSTextAttachment, BoundsObserving {

private var view: AttachmentContentView? = nil
private var content: AttachmentContent = .image(UIImage())
private var size: AttachmentSize? = nil
Expand All @@ -54,10 +58,20 @@ open class Attachment: NSTextAttachment, BoundsObserving {

var cachedBounds: CGRect?

/// Identifier that uniquely identifies an attachment. Auto-generated.
public let id: String = UUID().uuidString
/// Governs if the attachment should be selected before being deleted. When `true`, tapping the backspace key the first time on range containing `Attachment` will only
/// select the attachment i.e. show as highlighted. Tapping the backspace again will delete the attachment. If the value is `false`, the attachment will be deleted on the first backspace itself.
public var selectBeforeDelete = false

public var isBlockType: Bool {
isBlockAttachment
}

public var isInlineType: Bool {
!isBlockAttachment
}

/// Attributed string representation of the `Attachment`. This can be used directly to replace a range of text in `EditorView`
/// ### Usage Example ###
/// ```
Expand Down
73 changes: 73 additions & 0 deletions Proton/Sources/Swift/Core/AsyncTaskScheduler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// AsyncTaskScheduler.swift
// Proton
//
// Created by Rajdeep Kwatra on 11/9/2023.
// Copyright © 2023 Rajdeep Kwatra. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

class AsyncTaskScheduler {
typealias VoidTask = () -> Void
private var executing = false

private var tasks = SynchronizedArray<(id: String, task: VoidTask)>()
private var scheduled = SynchronizedArray<String>()

var pending = false {
didSet {
guard pending == false else { return }
executeNext()
}
}

func clear() {
tasks.removeAll()
}

func enqueue(id: String, task: @escaping VoidTask) {
guard tasks.contains(where: { $0.id == id }) == false else { return }
self.tasks.append((id, task))
}

func dequeue(_ completion: @escaping (String, VoidTask?) -> Void) {
guard let task = self.tasks.remove(at: 0) else {
completion("", nil)
return
}
completion(task.id, task.task)
}

func executeNext() {
guard !pending else { return }
dequeue { id, task in
if let task {
self.pending = true
// A delay is required so that tracking mode may be intercepted.
// Intercepting tracking allows handling of user interactions on UI
DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) { [weak self] in
guard let self else { return }
if RunLoop.current.currentMode != .tracking {
task()
} else {
self.tasks.insert((id: id, task: task), at: 0)
}
self.pending = false
}
}
}
}
}
26 changes: 21 additions & 5 deletions Proton/Sources/Swift/Editor/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ open class EditorView: UIView {
let richTextView: RichTextView
let context: RichTextViewContext
var needsAsyncTextResolution = false
private let attachmentRenderingScheduler = AsyncTaskScheduler()

// Holds `attributedText` until Editor move to a window
// Setting attributed text without Editor being fully ready
Expand All @@ -136,6 +137,8 @@ open class EditorView: UIView {
/// Context for the current Editor
public let editorViewContext: EditorViewContext

public weak var asyncAttachmentRenderingDelegate: AsyncAttachmentRenderingDelegate?

@available(iOS 13.0, *)
public var textInteractions: [UITextInteraction] {
richTextView.interactions.compactMap({ $0 as? UITextInteraction })
Expand Down Expand Up @@ -425,6 +428,7 @@ open class EditorView: UIView {
pendingAttributedText = newValue
return
}
attachmentRenderingScheduler.clear()
// Clear text before setting new value to avoid issues with formatting/layout when
// editor is hosted in a scrollable container and content is set multiple times.
richTextView.attributedText = NSAttributedString()
Expand Down Expand Up @@ -1247,8 +1251,9 @@ extension EditorView {

func relayoutAttachments(in range: NSRange? = nil) {
let rangeToUse = range ?? NSRange(location: 0, length: contentLength)
richTextView.enumerateAttribute(.attachment, in: rangeToUse, options: .longestEffectiveRangeNotRequired) { (attach, range, _) in
guard let attachment = attach as? Attachment else { return }
richTextView.enumerateAttribute(.attachment, in: rangeToUse, options: .longestEffectiveRangeNotRequired) { [weak self] (attach, range, _) in
guard let self,
let attachment = attach as? Attachment else { return }

if attachment.isImageBasedAttachment {
attachment.setContainerEditor(self)
Expand Down Expand Up @@ -1278,14 +1283,25 @@ extension EditorView {
frame = CGRect(origin: adjustedOrigin, size: size)

if attachment.isRendered == false {
attachment.render(in: self)
if !isSettingAttributedText, let focusable = attachment.contentView as? Focusable {
focusable.setFocus()
if self.asyncAttachmentRenderingDelegate?.shouldRenderAsync(attachment: attachment) == true {
self.attachmentRenderingScheduler.enqueue(id: attachment.id) {
// Because of async nature the attachment may get scheduled again to be rendered.
// ignore the attachments that are already rendered
guard attachment.isRendered == false else { return }
attachment.render(in: self)
self.asyncAttachmentRenderingDelegate?.didRenderAttachment(attachment, in: self)
}
} else {
attachment.render(in: self)
if !self.isSettingAttributedText, let focusable = attachment.contentView as? Focusable {
focusable.setFocus()
}
}
}

attachment.frame = frame
}
attachmentRenderingScheduler.executeNext()
}
}

Expand Down
Loading

0 comments on commit 5b31164

Please sign in to comment.