$30 off During Our Annual Pro Sale. View Details »

君だけのGFMエディタを作ろう! / iOSDC Japan 2023

FromAtom
September 02, 2023

君だけのGFMエディタを作ろう! / iOSDC Japan 2023

iOSDC Japan 2023 レギュラートーク(20分)
2023/09/02 16:50〜Track C

https://fortee.jp/iosdc-japan-2023/proposal/17181b9b-3c8f-471f-93db-da377ad90ece

FromAtom

September 02, 2023
Tweet

More Decks by FromAtom

Other Decks in Programming

Transcript

  1. FromAtom
    ܅͚ͩͷGFMΤσΟλΛ࡞Ζ͏ʂ
    iOSDC Japan 2023
    2023/09/02 16:50~ Track C

    View Slide

  2. • ϐΫγϒגࣜձࣾ


    • iOSΞϓϦ෼໺ςοΫϦʔυ


    • ޷͖ͳSwift͸ guard
    2
    @FromAtom

    View Slide

  3. ࠓ೔ͷαϯϓϧϦϙδτϦ
    3
    https://github.com/FromAtom/iosdc-2023-gfm-editor-sample

    View Slide

  4. Markdownͷྺ࢙
    4
    ͬ͘͟Γ

    View Slide

  5. Markdownͱ͸
    • 2004೥ʹδϣϯɾάϧʔόʔʹΑͬͯ։ൃ͞Εͨ


    • ςΩετ͔ΒHTMLʹม׵͢Δ࢓૊ΈʢMarkdown.plʣ
    5

    View Slide

  6. CommonMarkͱ͸
    • Markdown.plΛ֦ுͨ͠ํݴ͕େྔʹੜ·Εͨ


    ‣ ֦ுྫɿςʔϒϧه๏ɺ٭஫ه๏


    • ͦΕΛ౷Ұ͢ΔͨΊʹCommonMark͕ੜ·Εͨ
    6

    View Slide

  7. GitHub Flavored Markdownͱ͸ʁ
    • CommonMarkΛ֦ுͨ͠ͷ͕GitHub Flavored MarkdownʢGFMʣ


    • GitHub্Ͱ࢖͑ΔMarkdownͱࣅͨ΋ͷ


    • ݫີʹ͸Լهͷ఺͕ҟͳ͍ͬͯΔ


    ‣ ֆจࣈ (ex. :smile:)


    ‣ ϝϯγϣϯ


    ‣ ίϛοτʢίϛοτIDʣɾΠγϡʔɾϦϙδτϦͷࢀর
    7

    View Slide

  8. ࠓ೔ͷ໨ඪ
    8

    View Slide

  9. ࠓ೔ͷ໨ඪ
    • GitHub Flavored MarkdownϕʔεͰ


    • ೖྗิॿ͕͋ͬͯ


    • γϯλοΫεϋΠϥΠτ͞ΕΔ


    • ΤσΟλʔΛ࡞Ζ͏
    9

    View Slide

  10. ೖྗิॿΛ࡞Ζ͏
    10

    View Slide

  11. ೖྗิॿͱ͸
    11
    Markdownه๏Λ؆୯ʹೖྗͰ͖Δ

    Ϙλϯ΍ิ׬ػೳ

    View Slide

  12. ͜͏͢ΔͱͰ͖ͦ͏
    12
    ΧʔιϧͷҐஔΛऔಘ͢Δ
    ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ
    ଠࣈͳΒ࡟আɾଠࣈ͡Όͳ͍ͳΒଠࣈه߸Λૠೖ
    1
    2
    3

    View Slide

  13. 13
    ਖ਼نදݱͰ࣮૷Ͱ͖Δͬ͠ΐ

    View Slide

  14. 14
    ͱΓ͋͑ͣ ଠࣈ Ͱ΍ͬͯΈΑ

    View Slide

  15. 15
    **太字**
    ΍ͬͯΈͨ݁Ռ

    View Slide

  16. 16
    **太字**
    ΍ͬͯΈͨ݁Ռ
    • ΧʔιϧͷલޙͰߦΛ෼ׂ

    View Slide

  17. 17
    ΍ͬͯΈͨ݁Ռ
    **太 字**
    • ΧʔιϧͷલޙͰߦΛ෼ׂ


    • ෼ׂޙʹ** Λਖ਼نදݱͰ୳ࠪ

    View Slide

  18. 18
    ΍ͬͯΈͨ݁Ռ
    • ΧʔιϧͷલޙͰߦΛ෼ׂ


    • ෼ׂޙʹ** Λਖ਼نදݱͰ୳ࠪ


    • ୳ࠪʹώοτͨ͠Βଠࣈ൑ఆ
    **太 字**

    View Slide

  19. 19
    ΍ɺ΍͔ͬͨ……ʁ

    View Slide

  20. 20
    ΍ͬͯΈͨ݁Ռ
    **太字**太字じゃない**太字**

    View Slide

  21. 21
    ΍ͬͯΈͨ݁Ռ
    **太字**太字 じゃない**太字**

    View Slide

  22. 22
    ΍ͬͯΈͨ݁Ռ
    **太字**太字 じゃない**太字**

    View Slide

  23. 23
    ΍ͬͯΈͨ݁Ռ
    **太字**太字じゃない**太字**
    ଠࣈ͡Όͳ͍ͷʹଠࣈ൑ఆ͞Εͯ͠·͏

    View Slide

  24. 24
    ͩΊ͡ΌΜ

    View Slide

  25. 25
    ਖ਼نදݱͰ͸͏·͍͔͘ͳ͍
    `**コードスパン内なので太字じゃない**`

    View Slide

  26. 26
    ਖ਼نදݱͰ͸͏·͍͔͘ͳ͍
    `**コードスパン内なので太字じゃない**`
    **`コードスパン全体が太字`**

    View Slide

  27. 27
    ਖ਼نදݱͰ͸͏·͍͔͘ͳ͍
    `**コードスパン内なので太字じゃない**`
    **`コードスパン全体が太字`**
    `**`太字記号がコードスパン内にあるので太字じゃない`**`

    View Slide

  28. 28
    ਖ਼نදݱͰ͸͏·͍͔͘ͳ͍
    `**コードスパン内なので太字じゃない**`
    **`コードスパン全体が太字`**
    `**`太字記号がコードスパン内にあるので太字じゃない`**`
    **複数行に


    またがる


    太字**


    View Slide

  29. 29
    ਖ਼نදݱͰ͸ݶք͕͋Δ

    View Slide

  30. swift-markdownΛ࢖͓͏
    30

    View Slide

  31. swift-markdownͱ͸ʁ
    • AppleެࣜͷMarkdownύʔβ


    • cmark-gfmΛར༻͍ͯ͠Δ


    ‣ ͜ΕΛ࢖͑͹GFMʹରԠͰ͖Δ


    • https://github.com/apple/swift-markdown
    31

    View Slide

  32. swift-markdownͱ͸ʁ
    32
    import Markdown


    let source = "This is a markup *document*."


    let document = Document(parsing: source)


    print(document.debugDescription())


    // Document


    // └─ Paragraph


    // ├─ Text "This is a markup "


    // ├─ Emphasis


    // │ └─ Text "document"


    // └─ Text "."

    View Slide

  33. ೖྗิॿʢଠࣈฤʣ
    33
    ΧʔιϧͷҐஔΛऔಘ͢Δ
    ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ
    ଠࣈه߸Λૠೖ or ࡟আ͢Δ
    1
    2
    3
    4
    ΧʔιϧҐஔΛswift-markdown͕ѻ͑ΔΑ͏ʹม׵͢Δ

    View Slide

  34. 34
    @available(iOS 3.2, *)


    @NSCopying var selectedTextRange: UITextRange? { get set }
    open var selectedRange: NSRange
    ΧʔιϧͷҐஔΛऔಘ͢Δ
    1
    OR

    View Slide

  35. • swift-markdownͰ͸ൣғΛࣔ͢ͷʹSourceRange͕࢖ΘΕΔ


    • ΧʔιϧҐஔΛSourceRangeʹม׵͢Δඞཁ͕͋Δ
    35
    /// A range in a source file.


    public typealias SourceRange = Range
    ΧʔιϧҐஔΛswift-markdown͕ѻ͑ΔΑ͏ʹม׵͢Δ
    2

    View Slide

  36. 36
    /// A location in a source file.


    public struct SourceLocation: Hashable, CustomStringConvertible, Comparable {


    public static func < (lhs: SourceLocation, rhs: SourceLocation) -> Bool {


    if lhs.line < rhs.line {


    return true


    } else if lhs.line == rhs.line {


    return lhs.column < rhs.column


    } else {


    return false


    }


    }


    /// The line number of the location.


    public var line: Int


    /// The number of bytes in UTF-8 encoding from the start of the line to the character at this source location.


    public var column: Int


    /// The source file for which this location applies, if it came from an accessible location.


    public var source: URL?


    /// Create a source location with line, column, and optional source to which the location applies.


    ///


    /// - parameter line: The line number of the location, starting with 1.


    /// - parameter column: The column of the location, starting with 1.


    /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific


    /// file or resource that needs to be identified.


    public init(line: Int, column: Int, source: URL?) {


    self.line = line


    self.column = column


    self.source = source


    }


    public var description: String {


    let path = source.map {


    $0.path.isEmpty


    ? ""


    : "\($0.path):"


    } ?? ""


    return "\(path)\(line):\(column)"


    }


    }
    SourceLocationͷ࣮૷

    View Slide

  37. 37
    public struct SourceLocation: Hashable, CustomStringConvertible, Comparable {


    /// Create a source location with line, column, and optional source to which the location applies.


    ///


    /// - parameter line: The line number of the location, starting with 1.


    /// - parameter column: The column of the location, starting with 1.


    /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific


    /// file or resource that needs to be identified.


    public init(line: Int, column: Int, source: URL?) {


    self.line = line


    self.column = column


    self.source = source


    }


    }

    View Slide

  38. 38
    public struct SourceLocation: Hashable, CustomStringConvertible, Comparable {


    /// Create a source location with line, column, and optional source to which the location applies.


    ///


    /// - parameter line: The line number of the location, starting with 1.


    /// - parameter column: The column of the location, starting with 1.


    /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific


    /// file or resource that needs to be identified.


    public init(line: Int, column: Int, source: URL?) {


    self.line = line


    self.column = column


    self.source = source


    }


    }

    View Slide

  39. SourceLocationͷ࣮૷
    39
    • line: શମͷԿߦ໨͔


    • column: ߦ಄͔ΒԿจࣈ໨͔

    View Slide

  40. 40
    public struct SourceLocation: Hashable, CustomStringConvertible, Comparable {


    /// The line number of the location.


    public var line: Int


    /// The number of bytes in UTF-8 encoding from the start


    of the line to the character at this source location.


    public var column: Int


    /// Create a source location with line, column, and optional source to which the location applies.


    ///


    /// - parameter line: The line number of the location, starting with 1.


    /// - parameter column: The column of the location, starting with 1.


    /// - parameter source: The URL in which the location resides, or `nil` if there is not a specific


    /// file or resource that needs to be identified.


    public init(line: Int, column: Int, source: URL?) {


    self.line = line


    self.column = column


    self.source = source


    }


    }

    View Slide

  41. SourceLocationͷ࣮૷
    41
    • line: શମͷnߦ໨


    ‣ n͸1࢝·Γ


    • column: ߦ಄͔Βmจࣈ໨


    ‣ m͸1࢝·Γ


    ‣ UTF-8ͰͷByte਺

    View Slide

  42. 😁


    ΧʔιϧҐஔΛSourceLocationʹม׵͢Ε͹OK
    42

    View Slide

  43. ΧʔιϧҐஔ
    43
    @NSCopying var selectedTextRange: UITextRange? { get set }
    textView.selectedRange.location // 文章全体のn文字目


    textView.selectedRange.length // locationからm文字目
    textView.selectedTextRange?.start // 文章の先頭からn文字目


    textView.selectedTextRange?.end // 文章の先頭からm文字目
    open var selectedRange: NSRange

    View Slide

  44. 😭


    ߦͳΜͯͳ͍
    44

    View Slide

  45. 💪


    UITextRange͔ΒSourceLocationʹม׵
    45

    View Slide

  46. 46
    import UIKit


    import Markdown


    extension UITextView {


    func sourceLocation() -> SourceLocation? {


    guard let selectedTextRange else {


    return nil


    }


    // ドキュメントの先頭からカーソル位置までの文字列を取得


    guard


    let beginningOfDocumentToCursorPositionRange = textRange(from: beginningOfDocument, to: selectedTextRange.start),


    let beginningOfDocumentToCursorPositionString = text(in: beginningOfDocumentToCursorPositionRange)


    else {


    return nil


    }


    let lineCount = beginningOfDocumentToCursorPositionString.components(separatedBy: .newlines).count


    // カーソルがある行の行頭からカーソル位置までの文字列を取得


    guard


    let lineRange = tokenizer.rangeEnclosingPosition(selectedTextRange.start, with: .line, inDirection: .storage(.forward)),


    let columnStringRange = textRange(from: lineRange.start, to: selectedTextRange.start),


    let columnString = text(in: columnStringRange)


    else {


    return nil


    }


    // SourceLocationのcolumnはUTF-8でカウントする


    // SourceLocationはカウントが1から始まるので1を足す


    let utf8BasedColumnCount = columnString.utf8.count + 1


    return SourceLocation(line: lineCount, column: utf8BasedColumnCount, source: nil)


    }


    }
    ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ
    ※ϓϩμΫγϣϯͰ࣮૷͢Δ৔߹͸UITextViewΛExtension͠ͳ͍΄͏͕ྑ͍Α
    UITextRange͔ΒSourceLocationʹม׵͢Δίʔυ

    View Slide

  47. MarkupWalkerΛ࢖͏
    47
    import Markdown


    struct StrongWalker: MarkupWalker {


    var isStrong = false


    var cursorSourceLocation: SourceLocation


    mutating func visitStrong(_ strong: Strong) -> () {


    if let range = strong.range, range.contains(cursorSourceLocation) {


    isStrong = true


    return


    }


    descendInto(strong)


    }


    }
    Visitor patternʹै࣮ͬͯ૷
    ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ
    3

    View Slide

  48. MarkupWalkerΛ࢖͏
    48
    import Markdown


    struct StrongWalker: MarkupWalker {


    var isStrong = false


    var cursorSourceLocation: SourceLocation


    mutating func visitStrong(_ strong: Strong) -> () {


    if let range = strong.range, range.contains(cursorSourceLocation) {


    isStrong = true


    return


    }


    descendInto(strong)


    }


    }
    Visitor patternʹै࣮ͬͯ૷
    ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ
    3

    View Slide

  49. MarkupWalkerΛ࢖͏
    49
    import Markdown


    struct StrongWalker: MarkupWalker {


    var isStrong = false


    var cursorSourceLocation: SourceLocation


    mutating func visitStrong(_ strong: Strong) -> () {


    if let range = strong.range, range.contains(cursorSourceLocation) {


    isStrong = true


    return


    }


    descendInto(strong)


    }


    }
    Visitor patternʹै࣮ͬͯ૷
    ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ
    3

    View Slide

  50. 50
    guard let cursorSourceLocation = textView.sourceLocation() else {


    return


    }


    let document = Document(parsing: textView.text)


    var strongWalker = StrongWalker(cursorSourceLocation: cursorSourceLocation)


    strongWalker.visit(document)


    print(strongWalker.isStrong) // カーソルが太字だとtrue
    ΧʔιϧҐஔ͕ଠࣈ͔Ͳ͏͔֬ೝ͢Δ
    3

    View Slide

  51. σϞಈը
    51

    View Slide

  52. 52
    • ଠࣈ͡Όͳ͔ͬͨΒ


    ‣ "****"Λૠೖͯ͋͛͠Δ


    ‣ ૠೖޙʹΧʔιϧΛҠಈͯ͋͛͠Δͱ͞Βʹ਌੾


    • ଠࣈͩͬͨΒ


    ‣ ͜Ε΋swift-markdownͰରԠ͢Δ
    ଠࣈه߸Λૠೖ or ࡟আ͢Δ
    4

    View Slide

  53. ΧʔιϧҐஔͷଠࣈΛղআ
    53
    struct StrongRemover: MarkupRewriter {


    var cursorSourceLocation: SourceLocation


    func visitStrong(_ strong: Strong) -> Markup? {


    if let range = strong.range, range.contains(cursorSourceLocation) {


    return Text(strong.plainText)


    }


    return strong


    }


    }


    var strongRemover = StrongRemover(cursorSourceLocation: cursorSourceLocation)


    let result = strongRemover.visit(document)


    textView.text = result!.format()
    ※ෳࡶͳMarkdownʹରԠ͢Δʹ͸Text(strong.plainText)Λฦ͢ͷͰ͸ͳ͘ɺஸೡͳॲཧ͕ඞཁͰ͢
    MarkupRewriterΛར༻͢Δ

    View Slide

  54. 54
    ೖྗิॿ͕׬੒ 🎉

    View Slide

  55. γϯλοΫεϋΠϥΠτΛ࡞Ζ͏
    55

    View Slide

  56. γϯλοΫεϋΠϥΠτʢଠࣈͷΈʣ
    56
    1 MarkupWalkerͰଠࣈه๏ͷSourceRangeΛऔಘ
    SourceRangeΛNSRangeʹม׵
    NSAttributedStringΛੜ੒
    2
    3

    View Slide

  57. 57
    struct AttributedStringWalker: MarkupWalker {


    var strongSourceRanges: [SourceRange] = []


    mutating func visitStrong(_ strong: Strong) -> () {


    if let range = strong.range {


    strongSourceRanges.append(range)


    }


    descendInto(strong)


    }


    }
    1 MarkupWalkerͰଠࣈه๏ͷSourceRangeΛऔಘ

    View Slide

  58. 58
    extension UITextView {


    func convertToNSRange(from sourceRange: SourceRange) -> NSRange? {


    guard


    let start = convertToBound(from: sourceRange.lowerBound),


    let end = convertToBound(from: sourceRange.upperBound)


    else {


    return nil


    }


    return NSRange(start..

    }


    private func convertToBound(from sourceLocation: SourceLocation) -> Int? {


    var currentLine: Int = 1


    var utf16CharCount: Int = 0


    for line in text.components(separatedBy: .newlines) {


    if currentLine == sourceLocation.line {


    let columnString = line.utf8.prefix(sourceLocation.column - 1)


    let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line)


    return utf16CharCount + columnStringUtf16Count


    }


    currentLine += 1


    utf16CharCount += line.utf16.count + "\n".utf16.count


    }


    return nil


    }


    }
    SourceRangeΛNSRangeʹม׵
    2
    ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ

    View Slide

  59. 59
    extension UITextView {


    func convertToNSRange(from sourceRange: SourceRange) -> NSRange? {


    guard


    let start = convertToBound(from: sourceRange.lowerBound),


    let end = convertToBound(from: sourceRange.upperBound)


    else {


    return nil


    }


    return NSRange(start..

    }


    private func convertToBound(from sourceLocation: SourceLocation) -> Int? {


    var currentLine: Int = 1


    var utf16CharCount: Int = 0


    for line in text.components(separatedBy: .newlines) {


    if currentLine == sourceLocation.line {


    let columnString = line.utf8.prefix(sourceLocation.column - 1)


    let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line)


    return utf16CharCount + columnStringUtf16Count


    }


    currentLine += 1


    utf16CharCount += line.utf16.count + "\n".utf16.count


    }


    return nil


    }


    }
    SourceRangeΛNSRangeʹม׵
    2
    ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ

    View Slide

  60. 60
    extension UITextView {


    func convertToNSRange(from sourceRange: SourceRange) -> NSRange? {


    guard


    let start = convertToBound(from: sourceRange.lowerBound),


    let end = convertToBound(from: sourceRange.upperBound)


    else {


    return nil


    }


    return NSRange(start..

    }


    private func convertToBound(from sourceLocation: SourceLocation) -> Int? {


    var currentLine: Int = 1


    var utf16CharCount: Int = 0


    for line in text.components(separatedBy: .newlines) {


    if currentLine == sourceLocation.line {


    let columnString = line.utf8.prefix(sourceLocation.column - 1)


    let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line)


    return utf16CharCount + columnStringUtf16Count


    }


    currentLine += 1


    utf16CharCount += line.utf16.count + "\n".utf16.count


    }


    return nil


    }


    }
    SourceRangeΛNSRangeʹม׵
    2
    ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ

    View Slide

  61. 61
    extension UITextView {


    func convertToNSRange(from sourceRange: SourceRange) -> NSRange? {


    guard


    let start = convertToBound(from: sourceRange.lowerBound),


    let end = convertToBound(from: sourceRange.upperBound)


    else {


    return nil


    }


    return NSRange(start..

    }


    private func convertToBound(from sourceLocation: SourceLocation) -> Int? {


    var currentLine: Int = 1


    var utf16CharCount: Int = 0


    for line in text.components(separatedBy: .newlines) {


    if currentLine == sourceLocation.line {


    let columnString = line.utf8.prefix(sourceLocation.column - 1)


    let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line)


    return utf16CharCount + columnStringUtf16Count


    }


    currentLine += 1


    utf16CharCount += line.utf16.count + "\n".utf16.count


    }


    return nil


    }


    }
    SourceRangeΛNSRangeʹม׵
    2
    ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ

    View Slide

  62. 62
    extension UITextView {


    func convertToNSRange(from sourceRange: SourceRange) -> NSRange? {


    guard


    let start = convertToBound(from: sourceRange.lowerBound),


    let end = convertToBound(from: sourceRange.upperBound)


    else {


    return nil


    }


    return NSRange(start..

    }


    private func convertToBound(from sourceLocation: SourceLocation) -> Int? {


    var currentLine: Int = 1


    var utf16CharCount: Int = 0


    for line in text.components(separatedBy: .newlines) {


    if currentLine == sourceLocation.line {


    let columnString = line.utf8.prefix(sourceLocation.column - 1)


    let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line)


    return utf16CharCount + columnStringUtf16Count


    }


    currentLine += 1


    utf16CharCount += line.utf16.count + "\n".utf16.count


    }


    return nil


    }


    }
    SourceRangeΛNSRangeʹม׵
    2
    ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ

    View Slide

  63. 63
    extension UITextView {


    func convertToNSRange(from sourceRange: SourceRange) -> NSRange? {


    guard


    let start = convertToBound(from: sourceRange.lowerBound),


    let end = convertToBound(from: sourceRange.upperBound)


    else {


    return nil


    }


    return NSRange(start..

    }


    private func convertToBound(from sourceLocation: SourceLocation) -> Int? {


    var currentLine: Int = 1


    var utf16CharCount: Int = 0


    for line in text.components(separatedBy: .newlines) {


    if currentLine == sourceLocation.line {


    let columnString = line.utf8.prefix(sourceLocation.column - 1)


    let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line)


    return utf16CharCount + columnStringUtf16Count


    }


    currentLine += 1


    utf16CharCount += line.utf16.count + "\n".utf16.count


    }


    return nil


    }


    }
    SourceRangeΛNSRangeʹม׵
    2
    ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ
    SourceLocation: UTF-8


    NSAttributedString: UTF-16
    ref: https://developer.apple.com/documentation/foundation/nsattributedstring/1418432-length
    Χ΢ϯτํ๏ͷҧ͍ʹ஫ҙ

    View Slide

  64. 64
    extension UITextView {


    func convertToNSRange(from sourceRange: SourceRange) -> NSRange? {


    guard


    let start = convertToBound(from: sourceRange.lowerBound),


    let end = convertToBound(from: sourceRange.upperBound)


    else {


    return nil


    }


    return NSRange(start..

    }


    private func convertToBound(from sourceLocation: SourceLocation) -> Int? {


    var currentLine: Int = 1


    var utf16CharCount: Int = 0


    for line in text.components(separatedBy: .newlines) {


    if currentLine == sourceLocation.line {


    let columnString = line.utf8.prefix(sourceLocation.column - 1)


    let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line)


    return utf16CharCount + columnStringUtf16Count


    }


    currentLine += 1


    utf16CharCount += line.utf16.count + "\n".utf16.count


    }


    return nil


    }


    }
    SourceRangeΛNSRangeʹม׵
    2
    ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ
    SourceLocation: UTF-8


    NSAttributedString: UTF-16
    ref: https://developer.apple.com/documentation/foundation/nsattributedstring/1418432-length
    Χ΢ϯτํ๏ͷҧ͍ʹ஫ҙ

    View Slide

  65. 65
    extension UITextView {


    func convertToNSRange(from sourceRange: SourceRange) -> NSRange? {


    guard


    let start = convertToBound(from: sourceRange.lowerBound),


    let end = convertToBound(from: sourceRange.upperBound)


    else {


    return nil


    }


    return NSRange(start..

    }


    private func convertToBound(from sourceLocation: SourceLocation) -> Int? {


    var currentLine: Int = 1


    var utf16CharCount: Int = 0


    for line in text.components(separatedBy: .newlines) {


    if currentLine == sourceLocation.line {


    let columnString = line.utf8.prefix(sourceLocation.column - 1)


    let columnStringUtf16Count = columnString.endIndex.utf16Offset(in: line)


    return utf16CharCount + columnStringUtf16Count


    }


    currentLine += 1


    utf16CharCount += line.utf16.count + "\n".utf16.count


    }


    return nil


    }


    }
    SourceRangeΛNSRangeʹม׵
    2
    ௕͍ͷͰޙ͔Βࢿྉ΍αϯϓϧϦϙδτϦΛݟͯͶ

    View Slide

  66. 66
    let document = Document(parsing: textView.text)


    var strongWalker = AttributedStringWalker()


    strongWalker.visit(document)


    let attributedString = NSMutableAttributedString(string: textView.text)


    for strongRange in strongWalker.strongSourceRanges {


    let range = textView.convertToNSRange(from: strongRange)!


    attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range)


    }

    textView.attributedText = attributedString
    NSAttributedStringΛੜ੒
    3

    View Slide

  67. 67
    let document = Document(parsing: textView.text)


    var strongWalker = AttributedStringWalker()


    strongWalker.visit(document)


    let attributedString = NSMutableAttributedString(string: textView.text)


    for strongRange in strongWalker.strongSourceRanges {


    let range = textView.convertToNSRange(from: strongRange)!


    attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range)


    }

    textView.attributedText = attributedString
    NSAttributedStringΛੜ੒
    3

    View Slide

  68. 68
    let document = Document(parsing: textView.text)


    var strongWalker = AttributedStringWalker()


    strongWalker.visit(document)


    let attributedString = NSMutableAttributedString(string: textView.text)


    for strongRange in strongWalker.strongSourceRanges {


    let range = textView.convertToNSRange(from: strongRange)!


    attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range)


    }

    textView.attributedText = attributedString
    NSAttributedStringΛੜ੒
    3

    View Slide

  69. 69
    let document = Document(parsing: textView.text)


    var strongWalker = AttributedStringWalker()


    strongWalker.visit(document)


    let attributedString = NSMutableAttributedString(string: textView.text)


    for strongRange in strongWalker.strongSourceRanges {


    let range = textView.convertToNSRange(from: strongRange)!


    attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range)


    }

    textView.attributedText = attributedString
    NSAttributedStringΛੜ੒
    3

    View Slide

  70. 70
    let document = Document(parsing: textView.text)


    var strongWalker = AttributedStringWalker()


    strongWalker.visit(document)


    let attributedString = NSMutableAttributedString(string: textView.text)


    for strongRange in strongWalker.strongSourceRanges {


    let range = textView.convertToNSRange(from: strongRange)!


    attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range)


    }

    textView.attributedText = attributedString
    NSAttributedStringΛੜ੒
    3

    View Slide

  71. 71
    let document = Document(parsing: textView.text)


    var strongWalker = AttributedStringWalker()


    strongWalker.visit(document)


    let attributedString = NSMutableAttributedString(string: textView.text)


    for strongRange in strongWalker.strongSourceRanges {


    let range = textView.convertToNSRange(from: strongRange)!


    attributedString.addAttributes([.font: UIFont.systemFont(ofSize: 20, weight: .bold)], range: range)


    }

    textView.attributedText = attributedString
    NSAttributedStringΛੜ੒
    3

    View Slide

  72. γϯλοΫεϋΠϥΠτ׬੒ʂ
    72

    View Slide

  73. ೖྗิॿͱ


    γϯλοΫεϋΠϥΠτΛ


    ଞͷه๏ʹରͯ͠΋࣮૷͢Ε͹׬੒
    73

    View Slide

  74. 74
    ΋ͬͱஸೡʹγϯλοΫεϋΠϥΠτΛ࡞Δ
    https://fortee.jp/iosdc-japan-2020/proposal/32f815cc-8b16-4321-9cf4-c74f70287190

    View Slide

  75. Preview͸Ͳ͏ͨ͠Βʁ
    75

    View Slide

  76. Preview
    76
    NSAttributedStringͱSwiftUIΛࢼ͢

    View Slide

  77. 77
    SwiftUI

    View Slide

  78. 78
    NSAttributedString

    View Slide

  79. Ͳ͏͢ΔͱΑ͍ʁ
    79
    • swift-markdownͰHTMLʹม׵ͯ͠WebViewͰදࣔ


    • keitaoouchi/MarkdownView ͳͲͷϥΠϒϥϦΛར༻͢Δ


    • markedjs/marked ΛಡΈࠐΜͩHTMLΛWebViewͰදࣔ͢Δ

    View Slide

  80. Ͳ͏͢ΔͱΑ͍ʁ
    80
    • swift-markdownͰHTMLʹม׵ͯ͠WebViewͰදࣔ


    • keitaoouchi/MarkdownView ͳͲͷϥΠϒϥϦΛར༻͢Δ


    • markedjs/marked ΛಡΈࠐΜͩHTMLΛWebViewͰදࣔ͢Δ
    ΞϓϦͷ࢓༷ʹ߹Θͤͯબ΅͏

    View Slide

  81. ·ͱΊ
    81
    • Markdownͷೖྗิॿ͸ਖ਼نදݱͰ͸೉͍͠


    • ؤுͬͯSourceLocationΛม׵͠Α͏


    • MarkupWalker, MarkupRemoverΛ࢖͑͹͍͍ͩͨͳΜͰ΋Ͱ͖Δ

    View Slide

  82. ͕࣌ؒ༨ͬͨ࣌ͷTips
    82

    View Slide

  83. ͜ͷೖྗิॿόʔΛ


    ΩʔϘʔυʹ௥ैͤ͞Δํ๏
    83
    iOS15+

    View Slide

  84. StoryboardͰઃఆ͢Δํ๏
    84

    View Slide

  85. StoryboardͰઃఆ͢Δํ๏
    85

    View Slide

  86. StoryboardͰઃఆ͢Δํ๏
    86

    View Slide

  87. StoryboardͰઃఆ͢Δํ๏
    87

    View Slide

  88. ίʔυ্Ͱઃఆ͢Δํ๏
    88
    NSLayoutConstraint.activate([


    view.keyboardLayoutGuide.topAnchor.constraint(equalTo: バーのView.bottomAnchor, constant: 0)


    ])

    View Slide

  89. ίʔυ্Ͱઃఆ͢Δํ๏
    89
    NSLayoutConstraint.activate([


    view.keyboardLayoutGuide.topAnchor.constraint(equalTo: バーのView.bottomAnchor, constant: 0)


    ])

    View Slide