見出し画像

ノートアプリのテキストエディタの解体新書 #iosdc #a

かっくん / iOS Developer

この記事はiOSDC Japan 2022で登壇した内容を書き起こしたものです。発表資料はこちら。

note

noteのPCでの表示

noteはクリエイターが文章や画像、音声、動画を投稿して、ユーザーがそのコンテンツを楽しんで応援できるメディアプラットフォームです。

note(ノート)

note iOSアプリ

noteにはウェブ版とiOS / Android版のアプリがあります。現時点(2022/8月末)ではウェブにはない機能もいくつかありますが、基本的な機能である記事を書いたり読んだり、気に入った記事に対してスキを押したり、コメントをしたりすることができます。
noteのiOSアプリは2014年から運用しているので約8年間運用を続けています。
元々はObjective-Cが大半だったものを少しずつリニューアル・リファクタリングをして現在では98%以上がSwiftでできています。
最近ではホーム画面のUIで実験をしたり、プッシュ通知をより活用するようにしたりしています。

テキストエディタ

その中でもテキストエディタはnoteの「読む」、「書く」の体験のうち「書く」を担う重要な画面です。
文字以外にも画像やファイル、URLの埋め込みや罫線の入力、箇条書きにも対応しました。
文字にも見出しや小見出し、本文や引用、コードの入力に対応しています。
仕様も複雑で手をつけるのが難解だった画面です。
2020年の9月ごろにリニューアルをして、そこから少しずつ改善したり機能追加をしています。

そもそもエディタをどう設計するか

そもそもこのような画面をどのように設計するとよいでしょうか?

  1. 1つのUITextViewとattributedStringを駆使するパターン

    • UI的にはシンプルにできそう

    • ⌘+Aで全て選択ができる

    • 画像や埋め込み、リストやファイルアップロードなどの対応可能性が不透明

  2. 複数のUITextViewを配置するパターン

    • UIは複雑になる

    • しかしその分拡張性は高く後々の機能追加には柔軟に対応できそう

上記の検討をした結果noteのiOSアプリでは複数のUITextViewを配置するパターンを利用することにしました。

テキストエディタの構成

大まかな構成

テキストエディタの大まかな構成図です。

  • 見出し画像を設定する領域

  • タイトルを入力する領域

  • 本文を入力する領域

  • 文字数をカウントする領域

  • ツールバー

があります。さらに文字入力の詳細を見てみます。

文字入力の領域

テキストエディタはscrollViewの中に複数のtextViewを持ちます。
こういった構成の画面を実装するとなった時に皆さんはどのように設計しますでしょうか?
UICollectionViewの利用を検討する方もいるかもしれません。
UICollectionViewCompositionalLayoutを利用すればレイアウトは柔軟に構築することができそうです。
ただ、セル内に設置したTextViewの高さを追従してレイアウトを更新するのは地味に面倒そうだなと思いました。
さらに、UICollectionViewはViewとDataSourceを分けて考える必要があり、複雑なデータを同期し続ける必要があります。
UIStackViewではどうでしょうか?
コンテンツは全てStackView内に保持するのでデータの同期の心配は不要です。
TextViewの高さを追従できれば意外といいのではないかと思いました。
ただし、UIのインスタンスを全部保持することになるのであまりにも文字数が多かったりブロックが多かったりするとと辛くはなるかもしれません。
現状の構成としてはUIScrollViewの中にUIStackViewを配置して、その中にUITextViewなどをラップしたカスタムビューを配置するようにしています。

スクロールを追従する処理について

まずは雑に作ってみます。そうすると次のような挙動になります。

雑に作ってみた挙動

入力欄で改行すると入力欄の高さが変わらないので入力した文字が見えなくなってしまいました。
入力欄は全て表示したいですよね。
ここから実装の詳細を説明します。
入力欄をラップする画面を作成します。

import UIKit

@MainActor public final class TextEditorItemViewUIView {
    override public init(frame: CGRect) {
        super.init(frame: frame)
        setUp()
    }

    public required init?(coder: NSCoder) {
        super.init(coder: coder)
        setUp()
    }

    @MainActor override public func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setUp()
    }

    private lazy var setUp: () -> Void = {
        backgroundColor = TextEditorConstant.Color.background
        addTextView()
        return {}
    }()

    public lazy var textView: TextEditorTextView = {
        let textView = TextEditorTextView()
        return textView
    }()

    private lazy var textViewHeightConstraint = textView.heightAnchor.constraint(equalToConstant: TextEditorConstant.minimumItemHeight)

    private func addTextView() {
        textView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(textView)
        NSLayoutConstraint.activate([
            textView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            trailingAnchor.constraint(equalTo: textView.trailingAnchor, constant: 16),
            textView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
            bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 8),
            textViewHeightConstraint
        ])
    }
}

UITextViewを配置するだけのカスタムUIViewを作成しました。
特に中身について細かく見る必要はありません。
ここに次のようなコードを足します。

import Combine

@MainActor public final class TextEditorItemViewUIView {
    private lazy var setUp: () -> Void = {
        backgroundColor = TextEditorConstant.Color.background
        addTextView()
        subscribeContentSize()
        return {}
    }()

    …

    private var cancellables: Set<AnyCancellable> = .init()

    private func subscribeContentSize() {
        textView.publisher(for: \.contentSize)
            .map { max(TextEditorConstant.minimumItemHeight, $0.height) }
            .removeDuplicates()
            .sink { [weak self] height in
                self?.textViewHeightConstraint.constant = height
                self?.invalidateIntrinsicContentSize()
            }
            .store(in: &cancellables)
    }
}

やっていることとしてはtextViewのcontentSizeの値を監視して、値に変化があればtextView自体の高さに変更を加えています。
これで次の画像のような挙動になります。

無事スクロールが追従されるように

無事にスクロールが追従されるようになりました。

Drag & Dropの実装

Drag & Dropの挙動

テキストや画像を長押しするとドラッグアンドドロップができます。これは複数ブロックの実装を選択したからこそできるものでもあります。
実装はUILongPressGestureRecognizerを利用して実現しています。
細かい実装についてみていきます。
最初にドラッグのイベントをdelegateに渡していきます。

let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPress(gesture:)))
addGestureRecognizer(longPressGestureRecognizer)

public protocol TextEditorItemViewDelegateAnyObject {
    func itemView(_ itemView: TextEditorItemView, didStartDraggingAt point: CGPoint)
    func itemView(_ itemView: TextEditorItemView, didChangeDraggingAt point: CGPoint)
    func itemView(_ itemView: TextEditorItemView, didEndDraggingAt point: CGPoint)
}

@objc private func longPress(gesture: UILongPressGestureRecognizer) {
    let currentPosition = gesture.location(in: gesture.view)
    switch gesture.state {
    case .began:
        delegate?.itemView(self, didStartDraggingAt: currentPosition)
    case .changed:
        delegate?.itemView(self, didChangeDraggingAt: currentPosition)
    case .ended:
        delegate?.itemView(self, didEndDraggingAt: currentPosition)
    default:
        break
    }
}

続いてドラッグ中に表示する画像を取得するsnapshotメソッドを実装します。

import Combine
import UIKit

public extension UIView {
    func snapshot() -> AnyPublisher<UIImage?Never> {
        let size = bounds.size
        return Deferred { [weak selfin
            Future<UIImage?Never> { [weak self] promise in
                DispatchQueue.main.async { [weak selfin
                    let format = UIGraphicsImageRendererFormat()
                    let renderer = UIGraphicsImageRenderer(size: size, format: format)
                    let image = renderer.image { [weak self_ in
                        guard let self = self else { return }
                        self.drawHierarchy(inself.bounds, afterScreenUpdates: true)
                    }
                    promise(.success(image))
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

ドラッグ中に表示されるドロップができる領域のUIを作成します。

import UIKit

final class TextEditorDragPreviewViewUIView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUp()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setUp()
    }

    private lazy var heightConstraint = heightAnchor.constraint(equalToConstant: 3)

    private lazy var setUp: () -> Void = {
        translatesAutoresizingMaskIntoConstraints = false
        heightConstraint.isActive = true
        addLineView()
        return {}
    }()

    private lazy var lineView: UIView = {
        let view = UIView()
        view.backgroundColor = TextEditorConstant.Color.point
        view.accessibilityIdentifier = #function
        return view
    }()

    private func addLineView() {
        lineView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(lineView)
        NSLayoutConstraint.activate([
            lineView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            trailingAnchor.constraint(equalTo: lineView.trailingAnchor, constant: 16),
            lineView.centerYAnchor.constraint(equalTo: centerYAnchor),
            lineView.heightAnchor.constraint(equalToConstant: 3)
        ])
    }
}

先ほど作成したプレビューの領域の表示・非表示の操作をするメソッドを作成しましす。

func removeDragPreviewView() {
    stackView.arrangedSubviews.forEach {
        guard let previewView = $0 asTextEditorDragPreviewView else { return }
        stackView.removeArrangedSubview(previewView)
        previewView.removeFromSuperview()
    }
}

func showDragPreviewView() {
   stackView.arrangedSubviews.enumerated().reversed().forEach {
        let offset = $0.offset
        guard offset > 0 else { return }
        let preview = TextEditorDragPreviewView()
        preview.isHidden = true
        stackView.insertArrangedSubview(preview, at: offset + 1)
    }
}

ドラッグ開始時の実装です。

public func itemView(_ itemView: TextEditorItemView, didStartDraggingAt point: CGPoint) {
    showDragPreviewView()
    itemView.snapshot().sink { [weak selfweak itemView] image in
        guard
            let self = self,
            let image = image,
            let itemView = itemView else { return }
        let convertedPoint = itemView.convert(point, to: self.view)
        let imageView = UIImageView(image: image)
        imageView.contentMode = .scaleAspectFill
        imageView.frame = CGRect(
            x: convertedPoint.x - image.size.width / 4,
            y: convertedPoint.y - image.size.height / 4,
            width: image.size.width / 2,
            height: image.size.height / 2
        )
        imageView.alpha = 0.5
        imageView.accessibilityIdentifier = "dragPreviewImageView"
        self.view.addSubview(imageView)
        self.dragPreviewImageView = imageView
    }
    .store(in: &cancellables)
    UIView.animate(withDuration: 0.3) { [weak itemView] in
        guard let itemView = itemView else { return }
        itemView.alpha = 0.5
        itemView.transform = CGAffineTransform.identity.scaledBy(x: 0.8, y: 0.8)
    }
}

ドラッグで移動した時の実装です。
プレビューの表示位置を調整しています。
状況に応じてスクロールする処理を入れています。

public func itemView(_ itemView: TextEditorItemView, didChangeDraggingAt point: CGPoint) {
    guard let imageView = dragPreviewImageView, let image = dragPreviewImageView?.image else { return }
    scrollIfNeeded(for: itemView, at: point)
    let convertedPoint = itemView.convert(point, to: view)
    imageView.frame = CGRect(
        x: convertedPoint.x - image.size.width / 4,
        y: convertedPoint.y - image.size.height / 4,
        width: image.size.width / 2,
        height: image.size.height / 2
    )
    showCurrentDragItem(with: itemView, at: point)
}

func scrollIfNeeded(for itemView: TextEditorItemView, at point: CGPoint) {
    let convertedPoint = itemView.convert(point, to: view)
    scrollIfNeeded(at: convertedPoint)
}

func scrollIfNeeded(at convertedPoint: CGPoint) {
    let thresholdRate: CGFloat = 0.1 // %
    let move: CGFloat = 30.0
    let top = view.bounds.size.height * thresholdRate
    let bottom = view.bounds.size.height - top
    if top > convertedPoint.y {
        if scrollView.contentOffset.y - move > 0 {
            scrollView.contentOffset.y -= move
        } else {
            scrollView.contentOffset.y = 0
        }
    } else if bottom < convertedPoint.y {
        if (scrollView.contentOffset.y + scrollView.bounds.size.height + move) < scrollView.contentSize.height {
            scrollView.contentOffset.y += move
        }
    }
}

最後にドラッグが終了した時の実装です。

public func itemView(_ itemView: TextEditorItemView, didEndDraggingAt point: CGPoint) {
    if let previewView = currentPreviewView(for: itemView, at: point) {
        stackView.removeArrangedSubview(itemView)
        itemView.removeFromSuperview()
        if let previewIndex = stackView.arrangedSubviews.firstIndex(of: previewView) {
            stackView.insertArrangedSubview(itemView, at: previewIndex)
        }
    }
    hideCurrentDragItem()
    dragPreviewImageView?.image = nil
    dragPreviewImageView?.removeFromSuperview()

    UIView.animate(withDuration: 0.3) { [weak itemView] in
        guard let itemView = itemView else { return }
        itemView.alpha = 1
        itemView.transform = .identity
    }

完全にコードが網羅できているわけではないのですが、雰囲気は伝わったかなと思います。

iPadのレイアウト対応

特に何も考えないで実装をするとiPadでは次のような表示になってしまいます。

iPadでそのまま表示してみる

そこでreadableContentGuideを利用してiPadでもみやすいようにレイアウトを調整してみましょう。
readableContentGuideはiOS 9から使用可能になったレイアウトガイドです。端末ごとに読みやすい幅の表現をしてくれます。
元々のコードが下記のようなコードでした。

stackView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stackView)
NSLayoutConstraint.activate([
    stackView.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor),
    scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: stackView.trailingAnchor),
    stackView.topAnchor.constraint(equalTo: scrollView.topAnchor),
    scrollView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor)
])

これを次のように書き換えます。

stackView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stackView)
NSLayoutConstraint.activate([
    stackView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
    view.readableContentGuide.trailingAnchor.constraint(equalTo: stackView.trailingAnchor),
    stackView.topAnchor.constraint(equalTo: scrollView.topAnchor),
    scrollView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor)
])

そうすると

readableContentGuideに沿ってレイアウトしてみる

このような形で読みやすい幅を提供してくれます。
さて、このままで大丈夫でしょうか?iPhoneでも念の為動作を確認してみましょう。

iPhoneでの表示
赤い部分に謎のマージンができた

謎のマージンができた気がします。
これを調整してみます。

viewRespectsSystemMinimumLayoutMargins = false
view.layoutMargins = .zero
マージンを調整してみた

いい感じになった気がしますね。
ただし、注意点もあって、readableContentGuideを利用するとlayoutMarginsが全てのsubviewsに影響を及ぼします。viewRespectsSystemMinimumLayoutMarginsを無効にした上で、全てのsubviewsのlayoutMarginsを.zeroにする必要があります。
readableContentGuideについては同僚がまとめてくれているので参考にしてください。

テキストの操作について

テキストエディタなのでテキスト自体の操作も行います。例えば次のような仕様があった場合にどのようにするといいでしょうか?

  • 2回改行を入力したら新しいブロックを作成

  • テキスト入力欄の先頭で文字を削除しようとしたら、ブロックを削除、もしくは結合する

テキスト変換の処理はUITextViewDelegateのtextView(_:shouldChangeTextIn:replacementText:)メソッドで行なっています。(これがあまりよくないとは分かりつつ、よりよい方法が思いつかない)
まずは共通のインターフェースで呼び出すようにprotocolを定義します。
結果がtrueを返した場合はテキスト処理を何もしないイメージです。

import UIKit
// trueを返す場合には何もしない
public protocol TextEditorConverterAnyObject {
    var textViewDelegate: TextEditorTextViewDelegate? { get set }
    func callAsFunction(
        _ textView: TextEditorTextView,
        shouldChangeTextIn range: NSRange,
        replacementText text: String
    ) -> Bool
}

改行が入力された場合の処理は次のようにしています。
文字の途中で実行された場合にはブロックの分割、最後で実行された場合には新しくブロックを作るようなイメージです。

import UIKit

public final class DoubleNewLineConverterTextEditorConverter {
    public weak var textViewDelegate: TextEditorTextViewDelegate?

    private var isLastNewLine: Bool = false

    public func callAsFunction(_ textView: TextEditorTextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        let newLineCharacterSet = CharacterSet.newlines
        let isNewLine: Bool
        if let unicodeScalar = text.unicodeScalars.first, newLineCharacterSet.contains(unicodeScalar) {
            isNewLine = true
        } else {
            isNewLine = false
        }

        if isNewLine {
            if isLastNewLine {
                if (textView.text as NSString).length == range.location {
                    textViewDelegate?.textViewAdd(textView)
                } else {
                    textViewDelegate?.textView(textView, separateAt: range)
                }
                textView.removeLastNewLine()
                isLastNewLine = false
                return false
            } else {
                isLastNewLine = true
                return true
            }
        } else {
            isLastNewLine = false
            return true
        }
    }
}

テキストの削除が呼び出された場合の実装です。
入力欄の先頭で更にtextViewの文字が空の場合には削除、文字がある場合には前のブロックと結合を試みます。

import UIKit

public final class RemoveTextConverterTextEditorConverter {
    public weak var textViewDelegate: TextEditorTextViewDelegate?
    public func callAsFunction(_ textView: TextEditorTextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        if range.location == 0, range.length == 0, text.isEmpty {
            if textView.text.isEmpty {
                textViewDelegate?.textViewDeleteIfNeeded(textView)
                return false
            } else {
                textViewDelegate?.textViewJoinIfNeeded(textView)
                return false
            }
        } else {
            return true
        }
    }
}

最後にUITextViewDelegateが呼び出されるタイミングでこれらを実行します。

public var textConverters: [TextEditorConverter] = [
    RemoveTextConverter(),
    DoubleNewLineConverter()
]

public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    textConverters.allSatisfy { converter in
        guard let textView = textView asTextEditorTextView else { return false }
        converter.textViewDelegate = textViewDelegate
        return converter(textView, shouldChangeTextIn: range, replacementText: text)
    }
}

テキストの操作はこれ以外にもMarkdown形式のテキストが入力されたら変換するなどの処理を行なっていますが、基本的な考え方はこれらと同様です。
役割ごとにclassを作ることでテストを書きやすいように意識しています。

画像・ファイルアップロード・埋め込み

テキストエディタには画像やリンク、ファイルの埋め込みが可能です。
これらは前述したTextViewをラップしているViewを拡張して中身の出し分けをおこなっています。
アップロード処理などのAPIの呼び出しはこの各Viewの中に役割を持たせています。(失敗した場合のリロード処理なども管理しやすいため)
イメージ的にはこのようなコードでハンドリングをしているだけです。

var item: TextEditorItem! {
    didSet {
        handleItem(from: oldValue)
    }
}

private func handleVisible(with item: TextEditorItem, _ oldItem: TextEditorItem?) {
    if case .image = item {
        hideTextView()
        hidePlaceholder()
        hideEmbedView()
        showImageView()
    } else if case .embed = item {
        hideTextView()
        hidePlaceholder()
        hideImageView()
        showEmbedView()
    } else {
        hideImageView()
        hideEmbedView()
        showTextView()
        handlePlaceholderView()
    }
}

ツールバー

ツールバーのUI

ツールバーはUIToolbarは使わずにUIViewを拡張してツールバーっぽい見た目にしています。
これは中にscrollViewを入れることを想定しているためです。
見た目的にはアイコンのみのボタンが並ぶのでアクセシビリティの観点で注意が必要です。具体的にはaccessibilityLabelの設定を忘れないようにします。

キーボードショートカット

キーボードショートカットの説明

キーボードショートカットの実装にはUIKeyCommandを利用します。
定義についてはWeb側の定義となるべく合わせるようにしています。
iOS 14と15からで実装の方法が変わったので注意が必要です。
ViewController側ではこのようなイメージです。

override var keyCommands: [UIKeyCommand]? {
    if #available(iOS 15.0, *) {
        return super.keyCommands
    } else {
        return (super.keyCommands ?? []) + supportedKeyCommands
    }
}

private var supportedKeyCommands: [UIKeyCommand] {
    [
        Self.boldKeyCommand,
        .
        .
        .
    ]
}

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
    if supportedKeyCommands.first(where: { $0.action == action }) != nil {
        return true
    } else {
        return super.canPerformAction(action, withSender: sender)
    }
}

static let boldKeyCommand = UIKeyCommand(action: #selector(toggleBoldface(_:)), input: "B", modifierFlags: [.command], discoverabilityTitle: “太字")
.
.
.

続いてAppDelegateでメニューを登録します。

override func buildMenu(with builder: UIMenuBuilder) {
    super.buildMenu(with: builder)
    guard builder.system == .main else { return }
    MenuBuilder.create(with: builder)
}

enum MenuBuilder {
    static func create(with builder: UIMenuBuilder) {
        let formatMenu = UIMenu(title: “フォーマット", options: .displayInline, children: [
            TextEditorViewController.boldKeyCommand,
            .
            .
            .
        ])
        builder.replace(menu: .format, with: formatMenu)
    }
}

キーボードショートカットの実装についてはこのような感じです。Catalystでメニューの実装経験があると同じような感じで実装できます。

記事の編集

保存済みの記事は編集時にエディタに状態が再現できる必要があります。
内部的にはHTMLを利用しているのでHTMLをパースします。
パーサーで困った不具合に出会ったのでここで紹介します。

パーサー

解析対象となるテキストデータが記述された言語の語彙や記法、文法などのルールを元に、記述内容を要素や属性などに分け、それらの間の関係を読み取って木構造(ツリー)などのデータ構造や何らかのデータ記述言語による表記として出力する。
なぜ

https://e-words.jp/w/パーサ.html

なぜパーサーを自作したのかというと、元々iOSにはNSXMLParserなどデフォルトでXMLをパースする仕組みが用意されていました。
しかし、HTMLにはbrタグやimgタグなどの単体で成立するタグがあり、XMLパーサーではうまくいかないということがありました。
パーサーの簡単な仕組み説明します。

  1. トークン(字句)を分割します(HTMLの場合には<>とそれ以外の文字列)

  2. タグと文字列を意味のある単位で結合して階層構造を生成(AST)
    例)root > a[href=https://example.com]{example}

  3. アプリケーション側でASTを受け取ってUIとして表示するなど利用

というイメージです。
最初に汎用的に利用できるようにジェネリクスで利用可能なclassを作成しました。

    enum ParserErrorError {
        case noHandler
    }

    let input: T
    var index: T.Index
    init(input: T) {
        self.input = input
        index = input.startIndex
    }

    func callAsFunction() -> R? {
        return nil
    }

    func element(at index: T.Index) -> T.SubSequence? {
        guard input.startIndex <= index, index < input.endIndex else { return nil }
        let end = input.index(index, offsetBy: 1)
        return input[index ..< end]
    }

    var current: T.SubSequence? {
        element(at: index)
    }

    var previous: T.SubSequence? {
        guard input.startIndex < index else { return nil }
        return element(at: input.index(index, offsetBy: -1))
    }

    var next: T.SubSequence? {
        guard input.endIndex > index else { return nil }
        return element(at: input.index(index, offsetBy: +1))
    }

    func moveNextIndex() {
        guard input.endIndex > index else { return }
        index = input.index(index, offsetBy: +1)
    }

    func move(until handler: (T.SubSequence) -> Bool) {
        while index < input.endIndex, let current = self.current, !handler(current) {
            moveNextIndex()
        }
    }
}

続いてトークンを分割します。
受け取った文字列の先頭からチェックして分割したい文字が来たらそこで区切る処理を最後まで繰り返しています。

class HTMLTokenParserParser<String, [HTMLToken]> {
    enum Delimiter {
        static let start = "<"
        static let end = ">"
        static let allCases: [String] = [Delimiter.start, Delimiter.end]
    }

    override func callAsFunction() -> [HTMLToken]? {
        var result: [HTMLToken] = []
        while let char = current.flatMap(String.init) {
            switch char {
            case Delimiter.start:
                result.append(.startDelimiter)
                moveNextIndex()
            case Delimiter.end:
                result.append(.endDelimiter)
                moveNextIndex()
            default:
                let text = scanText()
                result.append(.text(text))
            }
        }
        return result
    }

    func scanText() -> String {
        let startIndex = index
        move(until: { Delimiter.allCases.contains(String($0)) })
        let endIndex = index
        return String(input[startIndex ..< endIndex])
    }
}

実行結果はこちらです。

トークン分割の実行結果

分かりづらいかもしれませんが、トークンが正しく分割されていることがわかります。
果たしてこれで本当に大丈夫でしょうか?
トークン分割のクラスに渡す文字列を変えてみましょう。

<a href="https://example.com/"&gt;゚▽゚*)</a>

値の部分に顔文字らしきものを使ってみました。怪しい雰囲気が出てきましたね。結果はどうでしょうか?

値を変えてみた結果
表示されないはずの>が表示されている

開始タグの部分が何かおかしいです。終わるはずの部分が終わっていません。
よく見ると>と半濁点が一つの文字になってしまっています。
ここでは詳細を省きますが、AppleのOSでは半角の濁点、半濁点が前の文字と結合してしまうという問題があることがわかりました。
そこで次のように修正してみました。

class HTMLTokenParserParser<String.UnicodeScalarView, [HTMLToken]> {
    enum Delimiter {
        static let start = "<".unicodeScalars
        static let end = ">".unicodeScalars
    }

    override func callAsFunction() -> [HTMLToken]? {
        var result: [HTMLToken] = []
        while let char = current.flatMap(String.UnicodeScalarView.init) {
            if char.elementsEqual(Delimiter.start) {
                result.append(.startDelimiter)
                moveNextIndex()
            } else if char.elementsEqual(Delimiter.end) {
                result.append(.endDelimiter)
                moveNextIndex()
            } else {
                let text = scanText()
                result.append(.text(text))
            }
        }
        return result
    }

    func scanText() -> String {
        let startIndex = index
        move(until: {
            Delimiter.start.elementsEqual($0) || Delimiter.end.elementsEqual($0)
        })
        let endIndex = index
        return String(input[startIndex ..< endIndex])
    }
}

StringではなくUnicodeScalarsを利用するようにしてみました。
修正結果を見てみましょう。

正しくパースできている

修正してみた結果です。想定通りのパースができるようになりました。
もし、パーサーを作っている方がいましたら、半角の濁点、半濁点には要注意です。

パフォーマンスについて

パフォーマンスについてはどうでしょうか?
stackViewでの実装にした際の一番の懸念事項でした。
同僚とこの登壇について話していて皆が気になるポイントもそこじゃないかということだったのですが、どのように計測するのがいいのか難しいなと思っていました。
そこでこの記事をiOSアプリで書くことで試してみることにしました。
この部分初代iPhone SEで書いていますがいかがでしょうか?
少しもたつきを感じますが、なんとか記事を書くことはできそうです。

ここからはiPhone Xで書いています。どうでしょうか?iPhone SEに比べたらかなりスムーズに書くことができます。

課題・将来

ユーザービリティ・アクセシビリティ的にはまだまだ改善の余地があります。例えば

  • 別のエディタで記事を書いた後でペーストした場合にいい感じにブロックに分けたい

  • キーボードショートカットを更に拡張したい

    • OSのバージョンによって動くものと動かないものがあり対策が難しい

という課題があります。
また、TextKit2を利用すればTextView一つで済む未来とかあり得る?ような気もしているのですが、このあたりは全然調べてないので詳しい人がいたら教えてください。

まとめ

noteのiOSアプリのテキストエディタの実装について紹介しました。
GitHubにサンプルコードを掲載しているのでより詳細が見たい方はそちらを参照してください。

  • noteのiOSアプリのテキストエディタはscrollViewの中にstackViewを配置して実装しています

  • Drag & Dropやテキストの操作などはゴニョゴニョして頑張って実装しています

  • iPad向けのレイアウトにはReadable Content Guideを利用しています

  • ツールバーなどのアイコンにはアクセシビリティにも注意が必要です

  • パフォーマンスについてはiPhone X以降の端末なら1~2万字ぐらいの編集なら割と問題なく可能

ここではある程度わかりやすくするためにシンプルなサンプルを用意しましたが、実際には仕様は複雑で更にこれからも機能はどんどん増えていく予定です。
このような機能をさらに改善したい方、よりよい実装方法を提案してくれる方いましたら以下からご連絡をお待ちしています。

この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!