ノートアプリのテキストエディタの解体新書 #iosdc #a
この記事はiOSDC Japan 2022で登壇した内容を書き起こしたものです。発表資料はこちら。
note
note iOSアプリ
noteにはウェブ版とiOS / Android版のアプリがあります。現時点(2022/8月末)ではウェブにはない機能もいくつかありますが、基本的な機能である記事を書いたり読んだり、気に入った記事に対してスキを押したり、コメントをしたりすることができます。
noteのiOSアプリは2014年から運用しているので約8年間運用を続けています。
元々はObjective-Cが大半だったものを少しずつリニューアル・リファクタリングをして現在では98%以上がSwiftでできています。
最近ではホーム画面のUIで実験をしたり、プッシュ通知をより活用するようにしたりしています。
テキストエディタ
その中でもテキストエディタはnoteの「読む」、「書く」の体験のうち「書く」を担う重要な画面です。
文字以外にも画像やファイル、URLの埋め込みや罫線の入力、箇条書きにも対応しました。
文字にも見出しや小見出し、本文や引用、コードの入力に対応しています。
仕様も複雑で手をつけるのが難解だった画面です。
2020年の9月ごろにリニューアルをして、そこから少しずつ改善したり機能追加をしています。
そもそもエディタをどう設計するか
そもそもこのような画面をどのように設計するとよいでしょうか?
1つのUITextViewとattributedStringを駆使するパターン
UI的にはシンプルにできそう
⌘+Aで全て選択ができる
画像や埋め込み、リストやファイルアップロードなどの対応可能性が不透明
複数の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 TextEditorItemView: UIView {
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 TextEditorItemView: UIView {
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の実装
テキストや画像を長押しするとドラッグアンドドロップができます。これは複数ブロックの実装を選択したからこそできるものでもあります。
実装はUILongPressGestureRecognizerを利用して実現しています。
細かい実装についてみていきます。
最初にドラッグのイベントをdelegateに渡していきます。
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPress(gesture:)))
addGestureRecognizer(longPressGestureRecognizer)
public protocol TextEditorItemViewDelegate: AnyObject {
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 self] in
Future<UIImage?, Never> { [weak self] promise in
DispatchQueue.main.async { [weak self] in
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(in: self.bounds, afterScreenUpdates: true)
}
promise(.success(image))
}
}
}
.eraseToAnyPublisher()
}
}
ドラッグ中に表示されるドロップができる領域のUIを作成します。
import UIKit
final class TextEditorDragPreviewView: UIView {
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 as? TextEditorDragPreviewView 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 self, weak 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では次のような表示になってしまいます。
そこで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)
])
そうすると
このような形で読みやすい幅を提供してくれます。
さて、このままで大丈夫でしょうか?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 TextEditorConverter: AnyObject {
var textViewDelegate: TextEditorTextViewDelegate? { get set }
func callAsFunction(
_ textView: TextEditorTextView,
shouldChangeTextIn range: NSRange,
replacementText text: String
) -> Bool
}
改行が入力された場合の処理は次のようにしています。
文字の途中で実行された場合にはブロックの分割、最後で実行された場合には新しくブロックを作るようなイメージです。
import UIKit
public final class DoubleNewLineConverter: TextEditorConverter {
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 RemoveTextConverter: TextEditorConverter {
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 as? TextEditorTextView 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()
}
}
ツールバー
ツールバーは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をパースします。
パーサーで困った不具合に出会ったのでここで紹介します。
パーサー
なぜパーサーを自作したのかというと、元々iOSにはNSXMLParserなどデフォルトでXMLをパースする仕組みが用意されていました。
しかし、HTMLにはbrタグやimgタグなどの単体で成立するタグがあり、XMLパーサーではうまくいかないということがありました。
パーサーの簡単な仕組み説明します。
トークン(字句)を分割します(HTMLの場合には<>とそれ以外の文字列)
タグと文字列を意味のある単位で結合して階層構造を生成(AST)
例)root > a[href=https://example.com]{example}アプリケーション側でASTを受け取ってUIとして表示するなど利用
というイメージです。
最初に汎用的に利用できるようにジェネリクスで利用可能なclassを作成しました。
enum ParserError: Error {
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 HTMLTokenParser: Parser<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/">゚▽゚*)</a>
値の部分に顔文字らしきものを使ってみました。怪しい雰囲気が出てきましたね。結果はどうでしょうか?
開始タグの部分が何かおかしいです。終わるはずの部分が終わっていません。
よく見ると>と半濁点が一つの文字になってしまっています。
ここでは詳細を省きますが、AppleのOSでは半角の濁点、半濁点が前の文字と結合してしまうという問題があることがわかりました。
そこで次のように修正してみました。
class HTMLTokenParser: Parser<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万字ぐらいの編集なら割と問題なく可能
ここではある程度わかりやすくするためにシンプルなサンプルを用意しましたが、実際には仕様は複雑で更にこれからも機能はどんどん増えていく予定です。
このような機能をさらに改善したい方、よりよい実装方法を提案してくれる方いましたら以下からご連絡をお待ちしています。