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

8.8K Views

September 10, 22

スライド概要

iOSDC Japan 2022で発表してきた内容です。
note iOSアプリのテキストエディタを実装するにあたって主にハマったところ・苦労した点について紹介します。

CfPはこちら
note の iOS アプリのテキストエディタはテキストや画像、埋め込みなど様々な要素を入力できます。
また、今年には V4 エディタと呼んでいるエディタの拡張も行い、箇条書きや罫線などのサポートも追加しました。
スクロールする画面に複数の UITextView を入れる UI を作る時に、みなさんはどのように設計するでしょうか?
note の iOS アプリでは伸び縮みする画面を作る方法として、UIScrollView の中に UIStackView を入れて、その中に UITextView を含むビューを配置するように実装しました。
このセッションでは UIKit を活用して、伸び縮みするような画面を作る際の考え方と実装する際に困ったことなどをお話しします。

profile-image

noteという会社でiOSアプリの開発をしています。 個人アプリも色々あります。 # Type: https://type-markdown.app WebCollector: https://webcollector.app/ Pity: https://freetimepicker.firebaseapp.com

関連スライド

各ページのテキスト
1.

アプリの テキストエディタの解体新書 iOSDC Japan 2022 @fromkk 1

6.

https://fromkk.me/n/nc328f5c89b3c 6

7.

• noteの「読む」「書く」の体験のうち「書く」を担う重要な画⾯ • ⽂字以外にも、画像やファイル、URLの埋め込みや罫線、箇条書き をサポートしている • ⽂字も⾒出し、⼩⾒出し、本⽂、コード、引⽤ 7

8.

テキストエディタの歴史 2020/9 リニューアル 2021/11 2022/4 V4エディタbeta版リリース V4エディタ正式リリース 8

9.

テキストエディタの構成 見出し画像設定 タイトル入力 本文入力 文字数カウント ツールバー 9

10.

そもそもエディタをどう設計するか • ⼀つの UITextView と attributedString を駆使するパターン • UI的にはシンプルにできそう • ⌘+Aで全て選択ができる • 画像や埋め込み、リストやファイルアップロードなどの対応が必要だし、今後のアップ デートを考えるとどこまでできるのか不透明 • 複数の UITextView を配置するパターン • UIは複雑になる • しかしその分拡張性は⾼く後々の機能追加には柔軟に対応できそう 10

11.

そもそもエディタをどう設計するか • ⼀つの UITextView と attributedString を駆使するパターン • UI的にはシンプルにできそう • ⌘+Aで全て選択ができる • 画像や埋め込み、リストやファイルアップロードなどの対応が必要だし、今後のアップ デートを考えるとどこまでできるのか不透明 • 複数の UITextView を配置するパターン • UIは複雑になる • しかしその分拡張性は⾼く後々の機能追加には柔軟に対応できそう 11

12.

ScrollView内に複数のTextView ScrollView TextView TextView ListView TextView 12

13.

こういう時にどう実装する?🤔 13

14.

UICollectionView? • UICollectionViewCompositionalLayoutを利⽤すれば レイアウトの柔軟性はよさそう • セル内に設置したTextViewの⾼さを追従してレイアウトを更新 するの地味に⾯倒そう • セル内のコンテンツはView以外で管理する必要がありそう • Viewで更新したコンテンツをData Sourceにちゃんと同期 できないとセルの再利⽤で失敗しそう🤔 14

15.

UIStackView? • コンテンツは全てStackView内に保持するのでデータの同期 を考えなくていい • TextViewの⾼さをうまく追従できれば意外といいのでは?🤔 • UIのインスタンスを全部保持することになるのであまりにも⽂ 字数が多かったりブロックが多かったりするとと⾟くはなるか も😢 15

16.

今回はUIStackViewを利⽤ 16

17.

スクロールを追従 17

18.

まずは雑に作ってみる 18

19.

⼊⼒欄は全て表⽰したい 19

20.
[beta]
TextViewをラップする
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
])
}
}

20

21.
[beta]
contentSizeを監視
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)
}

21

22.

結果 22

23.

⼊⼒欄を全て表⽰するまとめ • stackViewにtextViewを置いただけでは追従してくれない • contentSizeを監視してtextViewの⾼さを調整 • subViewでinvalidateIntrinsicContentSize()を 実⾏することでstackViewのレイアウトが⾃動的に調整され る 23

24.

Drag & Drop 24

25.

Drag & Drop • テキストや画像を⻑押しするとドラッグ アンドドロップができる • UILongPressGestureRecognizer を利⽤して実現 25

26.

Drag & Dropの実装(1) 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 } } 26

27.
[beta]
Drag & Dropの実装(2)
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()
}
}
27

28.
[beta]
Drag & Dropの実装(3)
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: NSLayoutConstraint = 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)
])
}
}

28

29.

Drag & Dropの実装(4) 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) } } 29

30.
[beta]
Drag & Dropの実装(5)
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)
}
}
30

31.
[beta]
Drag & Dropの実装(6)
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
}
}
}
31

32.

Drag & Dropの実装(7) 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 } } 32

33.

iPadのレイアウト対応 33

34.

そのまま表⽰してみる 34

35.

readableContentGuide • iOS 9から使⽤可能になったレイアウトガイド • 読みやすい幅を表現してくれる https://developer.apple.com/documentation/uikit/uiview/1622644readablecontentguide 35

36.

サンプルコード(before) 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) ]) 36

37.

サンプルコード(after) 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) ]) 37

38.

サンプルコード(diff) stackView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stackView) NSLayoutConstraint.activate([ stackView.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor), scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), 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) ]) 38

39.

よさそう! 39

40.

iPhoneでは? 40

41.

ん? 41

42.

謎のマージンができた気がする 42

43.

readableContentGuideのマージン調整 viewRespectsSystemMinimumLayoutMargins = false view.layoutMargins = .zero 43

44.

結果 44

45.

注意点 • readableContentGuideを利⽤するとlayoutMarginsが全 てのsubviewsに影響を及ぼす • viewRespectsSystemMinimumLayoutMarginsを無効に した上で、全てのsubviewsのlayoutMarginsを.zeroにす る必要がある • 同僚がまとめてくれているので参考に https://zenn.dev/st43/articles/d27a68cd01a0b8 45

46.

テキストの操作 46

47.

テキストの操作 • 2回改⾏を⼊⼒したら新しいブロックを作成 • テキスト⼊⼒欄の先頭で⽂字を削除しようとしたらブロックを 結合、もしくは削除 これらをどのように⾏なっているか 47

48.

プロトコルを定義 import UIKit public protocol TextEditorConverter: AnyObject { var textViewDelegate: TextEditorTextViewDelegate? { get set } func callAsFunction( _ textView: TextEditorTextView, shouldChangeTextIn range: NSRange, replacementText text: String ) -> Bool } 48

49.
[beta]
改⾏時の処理
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
}
}
}

49

50.
[beta]
テキスト削除時の処理
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
}
}
}

50

51.
[beta]
UITextViewDelegate
• UITextViewDelegateのメソッドに役割ごとのclassを⽤
意して必要なものを⾜していく

• 個別にテストが書けるので便利
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)
}
}
51

52.

テキストの操作まとめ • delegateで受け取った処理を割と泥臭くハンドリングしてい る • 役割ごとにclassを作ってテストしやすくしていきたい 52

53.

画像・埋め込み 53

54.

画像・埋め込み • テキストエディタには画像やリンク、ファイルの埋め込みが可能 • TextViewをラップしているViewを拡張して中⾝の出し分けを ⾏う • アップロード処理などのAPIの呼び出し処理は個別にこのView 内で実施する • 画像やファイルのアップロードAPIや埋め込み⽤のAPIを叩いた 結果を表⽰する 54

55.

画像・埋め込み 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() } } 55

56.

ツールバー 56

57.

ツールバー • UIToolbarは使わずにUIViewをツールバーっぽい⾒た⽬で 利⽤している • scrollViewを設置したかったため • アイコンのみのボタンが並ぶのでアクセシビリティに要注意 • accessibilityLabelを設定するのを忘れない 57

58.

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

59.

キーボードショートカット • UIKeyCommandを利⽤ • Webの定義となるべく合わせて 実装する • iOS 14と15以降で実装の⽅法が 変わるので注意が必要 59

60.
[beta]
キーボードショートカット実装(1)
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: “太字")
.
.
.

ViewController側の実装
60

61.

キーボードショートカット実装(2) 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) } } AppDelegate.swift 61

62.

記事の編集 62

63.

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

64.

パーサー 解析対象となるテキストデータが記述された⾔語の語彙や記法、⽂法などの ルールを元に、記述内容を要素や属性などに分け、それらの間の関係を読み 取って⽊構造(ツリー)などのデータ構造や何らかのデータ記述⾔語による 表記として出⼒する。 引用: https://e-words.jp/w/パーサ.html 64

65.

なぜパーサーを⾃作したのか • 元々iOSにはNSXMLParserなどデフォルトでXMLをパース仕 組みは⽤意されている • ただし、HTMLにはbrやimgなどの単体で成⽴するタグがあ り、XMLパーサーではうまくいかない 65

66.
[beta]
パーサーの仕組み
1. トークン(字句)を分割する(HTMLの場合は<>とそれ以外)
例: [“<”, “a href=‘https://example.com/’”,
“>”, “example”, “<”, “/a”, “>”]
2. タグと⽂字列を意味のある単位で結合して階層構造を⽣成(AST)
例:root > a[href=https://
example.com/]{example}
3. アプリケーション側でASTを受け取ってUIとして表⽰するなど利
⽤する
66

67.
[beta]
Parser
class Parser<T: Collection, R> {
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()
}
}

67

68.
[beta]
トークン(字句)を分割する
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])
}
}

受け取った文字列の先頭からチェックして、分割したい文字が来たらそこで区切る
68

69.

実⾏してみる < a href=“https://example.com/” よさそう! 69 > example < /a >

70.

本当に⼤丈夫? 70

71.

渡す⽂字列を変えてみる <a href="https://example.com/">゚▽゚*)</a> 71

72.

結果 72

75.

結果 • 開始タグがおかしい(終わるはずの部分が終わっていない) • よく⾒てみると >゚ が1つの⽂字になってしまっている • ここでは詳細は省くがAppleのOSでは半⾓の濁点、半濁点が前の⽂字と 結合してしまう 75

76.
[beta]
修正してみる
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を操作するように修正
76

77.

修正結果 • Stringを操作するのではなくunicodeScalarsを操作する ようにしたら想定通りのパースができるようになった • もしパーサーを作っている⽅がいましたら半⾓濁点、半⾓半濁 点には要注意 77

78.

パフォーマンス 78

79.

パフォーマンス • stackViewを選択した場合の懸念事項 • どう測るのか悩んだのですが気になるのは体感 • 次のスライドから端末ごとに⻑い⽂章を書いてみて⽐較 79

80.

初代iPhone SE 80

81.

初代iPhone X 81

82.

課題・将来について 82

83.

課題・将来について • ユーザービリティ・アクセシビリティ的にはまだまだ改善の余地がある • 別のエディタで記事を書いた後でペーストした場合にいい感じにブ ロックに分けたい • キーボードショートカットを更に拡張したい • OSのバージョンによって動くものと動かないものがあり対策が難 しい • TextKit2を利⽤すればTextView⼀つで済む未来とかあり得る? 83

84.

まとめ 84

85.

まとめ • https://github.com/fromkk/TextEditorSample サンプルコードを掲載 • UIStackViewを利⽤して実装している • Drag & Dropやテキストの操作はゴニョゴニョ頑張っている • iPad向けにはReadable Content Guideを利⽤ • ツールバーのアイコンボタンなどアクセシビリティにも注意 • ⽂字列をパースする時にはunicodeScalars • パフォーマンスはiPhone X以降なら1~2万字ぐらいでも問題ない 85