文字っぽいの。

文字を書いています。写真も混ざります。

時代は着る布団。

かなり前から着る毛布が流行っており、冬場の寒さ対策グッズとして人気です。しかし、日本には古来から着る布団があります。

そう、半纏(綿入れ半纏)ですね。前から欲しかったのですが、Amazonブラックフライデーで安くなっていたので購入しました。もう売り切れてしまっていますが、下記の商品です。

早速使っているんですが、かなり暖かくて最高です。寒いとエアコンをつけて作業をするのですが、暖房を強くしすぎると暖かくはあるんですが頭がポワポワしてきます。一方で弱くすると薄っすらと寒さが身体にしみてきて、なんとも難しい。そんな時にこの半纏が便利です。

布団よろしく綿が全体に詰め込まれているので、もこもこのふかふかでとっても暖かい。寝れる。また、着る毛布のように長くないので、ちょっと席を立って物を取ったりとか、そういう動きがとても楽にできます。

デメリットとしては、もこもこしているのでベイマックスみたいになります。でかい。まぁこれで外に出るわけじゃないので良いでしょう。

今年の冬は、この半纏でぬくぬく快適に過ごせそうです。

UITextViewで `""` が `“”` に、 `--` が`—` に勝手に変換される問題の対処法

課題

UITextViewでエディタを作っていると、

  • "" が勝手に “”に変換される
  • '' が勝手に ‘’ に変換される
  • -- が勝手に に変換される

という状況に出会って困ることがあります。

これはなに?

これはスマート引用符とスマートダッシュという機能です。Macでコードや技術的な文章を書いている人は、

設定画面のこのチェックを外しましょうという記事に出会います。このスマート引用符とスマートダッシュiOS/iPadOSのUITextViewにも導入されているため、iPhone/iPadでも同様の自動変換が発生してしまいます。

対応策

OSの設定を変える

設定アプリ > 一般 > キーボード > スマート句読点をOFF

これでOS全体でスマート系(自動変換系)の機能がOFFになります。

UITextViewの設定を変える

OS側の設定でも変更可能ですが、わざわざOS設定を変えなくても特定のUITextViewでこれらの自動変換機能を止めたい場合があると思います。

UITextViewには smartQuotesTypesmartDashesTypeというプロパティがあるので、これらに .no を指定してあげるだけで良いです。

コード例としては、

override func viewDidLoad() {
    super.viewDidLoad()

    textView.smartDashesType = .no
    textView.smartQuotesType = .no
}

こんな感じ。

swift-markdownを利用してUITextViewのカーソル位置が特定記法であるか調べる。

やりたいこと

Markdownエディタを実装していくと、カーソルのいる場所がリストなのか太字なのか見出しなのかを知りたくなります。単純に記法によって見た目を変えたいのであれば、NSAttributedString.init(markdown:options:baseURL:) を使えばよいです。

一方で、エディタに太字ボタントグルボタン機能(はてなブログにもありますね)を作っていくと、「いまカーソルがいる場所は太字なのか」が知りたくなります。それが分かると

  • カーソル箇所が太字の場合:太字記法(****)を削除する
  • カーソル箇所が太字ではない場合:太字記法(****)を挿入する

といった処理を実装することが可能になります。

課題

太字かどうか確認する場合、簡素に考えると

  • カーソルがある行を取り出す
  • カーソル前後に文字列を2つに分割する
  • 分割した2つの文字列それぞれに ** があれば太字とする

という実装方法が思いつきます。これでもある程度は動くのですが、このように太字に指定される範囲が複数行にまたがる場合で困ります。

**これは
複数行にまたがる
太字**です

この場合カーソルがいる行だけ見ていては、太字かどうか判定できません。また、

`コードスパンなので**太字ではない**`

のように、使われる記号としては太字ではあるが、太字として解釈しない場合もあります。これらをまともに対応していくと、最終的に文章全体を解析して構文木を生成するMarkdownパーザを実装することになります。

実装方針

自分でMarkdownパーザを作るのも良いですが、すでにApple公式が作ったものがあるのでそちらを利用しましょう。

github.com

swift-markdowncmark-gfm を利用して実装されています。

カーソル位置を取得できるようにする

swift-markdownを使う前に、カーソル位置を取得できるようにしましょう。

import UIKit

final class ViewController: UIViewController {
    @IBOutlet weak var textView: UITextView! {
        didSet {
            textView.delegate = self
        }
    }
    @IBOutlet weak var label: UILabel!
}

extension ViewController: UITextViewDelegate {
    func textViewDidChangeSelection(_ textView: UITextView) {
        print(textView.selectedTextRange)
    }
}

こうすれば、カーソル位置がUITextRangeとして取得できます。

UITextRangeをSourceLocationに変換する

このカーソル位置をswift-markdownで利用している値に変換する必要があります。swift-markdownでは文章のどこに対象が存在しているかを示すために、SourceRangeが使われます。SourceRangeは

/// A range in a source file.
public typealias SourceRange = Range<SourceLocation>

https://github.com/apple/swift-markdown/blob/d491147940587dbadfb3472354f4d0c6e063e061/Sources/Markdown/Infrastructure/SourceLocation.swift#L64

と定義されているので、SourceLocationのRangeになります。そんなSourceLocationは

/// 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)"
    }
}

https://github.com/apple/swift-markdown/blob/d491147940587dbadfb3472354f4d0c6e063e061/Sources/Markdown/Infrastructure/SourceLocation.swift#L14

と定義されています。つまり、

  • line: 全体の何行目か
  • column: 行頭から何文字目か

という要素で対象の場所を示しています。ここでの注意点としては

  • カウントは0始まりではなく1始まるである
  • columnはUTF-8エンコードにおけるバイト数を用いる

事です。もうすでに面倒くさい匂いが漂っています。

さて、UITextViewの扱いに慣れた人は感づいていると思いますが、こんなカウント方法をとるRange要素はデフォルトには用意されていません。UITextViewでは UITextVIew.selectedRangeUITextView.selectedTextRange でそれぞれ NSRangeUITextRange が得られるのですが、それぞれざっくり説明すると

  • NSRange
    • locationlength が得られる
    • location: 文章の先頭からn文字目
    • length: locationからm文字分(範囲選択してないなら0)
  • UITextRange
    • startend が得られる
    • start: 文章の先頭からn文字目
    • end: 文章の先頭からm文字目(範囲選択していないなら start と同様

といったデータが得られます。何行かをそのまま返してくれるAPIは無いので、変換処理を愚直に実装していきます。

func convertToSourceLocation(from selectedTextRange: UITextRange?, in textView: UITextView) -> SourceLocation? {
    guard let selectedTextRange else {
        return nil
    }

    let location = textView.offset(from: textView.beginningOfDocument, to: selectedTextRange.start)

    var lead: Int = 0
    var trail: Int = 0
    var currentLine: Int = 0
    var column: Int = location
    for line in textView.text.components(separatedBy: .newlines) {
        trail = lead + line.count

        if lead <= location && location <= trail {
            // SourceLocationのcolumnはUTF-8でカウントするので変換する
            let utf8BasedColumnCount = line.prefix(column).utf8.count

            // SourceLocationは1からカウントが始まる世界なので合わせる
            return SourceLocation(line: currentLine + 1, column: utf8BasedColumnCount + 1, source: nil)
        }

        currentLine += 1
        column -= line.count + "\n".count  // 改行コード分を追加で減らす
        lead = trail + 1
    }

    return nil
}

これでUITextRangeをSourceLocationに変換できました。

MarkupWalkerでカーソル位置が太字か確かめる

ようやく swift-markdownを使う段階に来ました。swift-markdownではVisitor patternを推奨しています。詳しい説明は省くのですが、公式ドキュメントのVisitors, Walkers, and Rewriters を読むとなんとなく雰囲気がつかめると思います。

さっそく、MarkupWalker のprotocolを利用して実装をします。

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)
    }
}

このStrongWalkerとSourceLocationの変換処理を利用すると、下記の実装ができます。

extension ViewController: UITextViewDelegate {
    func textViewDidChangeSelection(_ textView: UITextView) {
        guard let cursorSourceLocation = convertToSourceLocation(from: textView.selectedTextRange, in: textView) else {
            return
        }

        let document = Document(parsing: textView.text)
        var strongWalker = StrongWalker(cursorSourceLocation: cursorSourceLocation)
        strongWalker.visit(document)

        print(strongWalker.isStrong) // => カーソルが太字記法内部にいるとtrue
    }
}

簡単なUIを作って様子を見る

ようやくロジック部分が完成したので、簡単なUIを作ってちゃんと動いているか見てみましょう。UILabelをおいて

if strongWalker.isStrong {
    label.text = "太字だよ"
} else {
    label.text = "太字じゃない"
}

という簡単なコードを追加します。

カーソル位置が太字か判定している様子

無事に動いていそうでめでたいですね。

注意点

swift-markdownMarkdownとして壊れた文字列でも問題なく解析してくれます。しかし、その場合はSourceRange, SourceLocationが正常に見えるが間違っている値で返ってきます。

まとめ

swift-markdownを使うことで、Markdownを解析することができます。しかし、swift-markdown内で使われるSourceLocationとSourceRangeは、UITextViewの世界とは異なる為に変換処理が必要かつ、1始まりのUTF-8カウントという特殊な世界です。

また、もしあなたがガッツリとMarkdownエディタを実装し始めたい場合は、 SourceRange <-> NSRange <-> UITextRange の変換迷宮に迷い込むでしょう。頑張って下さい。

素振り

最近、「エンジニアリングスキルを高めるにはどうしたらよいか」といったニュアンスの話をすることが何回かありました。エンジニアリングスキルにはコーディング能力だけではなく、チームワークだったりプロダクト志向だったりステークホルダーの調整だったり、そういうのも必要になってきますが、この記事ではスキルセットの1つであるコーディング能力について話します。

さて、「コーディング能力をあげるには?」に対する回答は人それぞれあるとは思いますが、自分は「コードを書かない限り、コードを書けるようにはならない。」と考えています。コーディング能力上げたかったらコード書け。

=完=

だと困ってしまうと思うので、1つの手法である「素振り」について書きます。これは、自分にWebアプリ開発のやりかたを教えてくれた先輩の受け売りです。

素振りとは「コードを書いて、他人が使えるようにする」までを1セットとした行動の事です。他人も使えるというのが重要です。コードを書いてローカル環境においてあったり、GitHubのプライベートリポジトリにPushするだけでは素振りではありません。例えば、

  • Webアプリを作ってデプロイして誰でも使えるようにする
  • ライブラリを作ってGitHubに公開して誰でも使えるようにする
  • スマホアプリを作ってAppStoreやPlayStoreで公開する
  • Chrome拡張を作ってChromeウェブストアで公開する

などが素振りになります。自分のアイディアを技術の力で実現し、それをリリースして、ユーザーからのフィードバックを得るという一連の流れで1回の素振りが終わります。

リリースまでしないといけないので、素振りは大変です。しかし、他人が使えるという状態にすることで、

  • 自分のアイディア実現の為に必須な機能はどれか
  • あったほうが便利だけど初期は無視していい機能はどれか
  • 無視して良いバグとダメなバグはどれか
  • ライブラリやIaaSなど開発効率をあげる仕組みはないか

などを考える必要がでてきます。というかこういうのを考えないと、「僕の考えた最強のサービス(ただし実装は脳内にある)」となって素振りを失敗します。

普段の仕事では自然と分業が進んでいくため、どうしても触れられる領域は狭くなります。一方素振りではチームメンバーが1人なので全部自分です。デザイン、実装、プライバシーポリシー・利用規約からマーケティングまで全部1人です。ライブラリを作る場合でもREADMEやドキュメントを整えたり、適切なライセンスを設定したり、CIの仕組みを整える必要があります。素振りをしていれば「やらざるを得ない」ため勝手に開発筋が鍛えられていきます。

最初のうちはリリースしても誰にも使われない事がほとんどでしょう。しかし素振りの中で学んだ技術や知識は確実に蓄積されていきます。そうやってコツコツと素振りを続けていくことで、一発当てる為の筋力がついてくると考えています。

ただし、野球やテニスなどスポーツにおける素振りと同じく「何も考えずにブンブンする」だけでは、少々筋力はつくかもしれませんがそれ以外の能力は向上しません。コーディングにおける素振りも同様で「使い慣れた技術で、作り慣れたものを作る」だけを続けていては効果が薄いでしょう。新しい知識を得てコードの書き方を変えてみたり、新しいライブラリを使ってみたり、工夫をしていくことが肝要です。

#iOSDC Japan 2022でLT登壇してきました!

帰ってきたオフライン開催!

今年のiOSDCはオフライン・オンライン両方のハイブリッド開催で、家で配信を見るのもよし、現地でトークを聞いたりブース出展を眺めるも良しの贅沢なカンファレンスになっていました。

3年ぶりに物理ブースが並んでいる様子をみると、感慨深くなりますね。

色んな人と久しぶりに物理会話できて楽しかったです。

LT登壇

めでたいことに今年も採択していただいたので、LTしてきました。なんとこれで5年連続登壇です!

このLTでは時間の都合上カットしたのですが、最も推奨されるサポートiOSバージョンの決め方は、

最新メジャーバージョン2つをサポートする。ただし、最新メジャーバージョンは出てすぐが不安定なので、マイナーバージョンが2くらいになって安定してきたら考える。

です。しかし、この決定方法はエンジニアにある程度 "力(りき)" がある場合にしか使えません。さらに、信頼関係が築けていない状態で「最新メジャーバージョン2つ!ビジネスの都合とか知らん!界隈ではこれが普通!」とゴリ押しをしては、エンジニア VS ビジネスという対立構造を生んでしまいます。

そういった悲しみを発生させず、チーム全員で目線を揃えてちょうどよい基準を決められるように、資料をまとめてみました。

資料のデザイン

最近流行っているリキッド系にしました。iOS 16もこんな感じ(もうちょっと3Dだけど)だし、なによりSplatoon3のバンカラジオもこんな感じ。 バンカラジオで使われてる画面転換も作りたかったんですが、Keynoteでやるには難しすぎて諦めました。

個人的には、ここのトランジションがかわいく作れたので満足しています。

ラーメン

昔のiOSDCではランチも提供されて(しかも旨い!)いたのですが、今年はご時世もありお水とジュースのみが提供されていました。 お昼は各自でなんとかするのですが、せっかく高田馬場という遠くの街に来たので普段行けないラーメン屋を巡っていました。

ピコピコポン

tabelog.com

ふく流らーめん 轍

tabelog.com

美味しかったです 🤤

まとめ

久しぶりの物理登壇はとても緊張しましたが、やっぱりとても良いものでした。感染症対策でランチも懇親会も無限コーヒーもなかったですが、やっぱり人間を対面して話せるというのはとても楽しかったです。

また、LT資料の準備を終わらせたいときに、オンラインでも配信があったので、聞きながら準備ができてとても助かりました。

来年もハイブリッド開催してもらえると嬉しいと思いつつ、スタッフさんに求められる機材操作能力がくっそ高そうで大変そうだなぁとも思いました。大変お疲れさまです……!

自分個人としては、5年連続登壇が嬉しかったのでここらで一段落したい気持ちもありつつ、来年も登壇できたらいいなぁという気持ちもあります。CM作成もやりたいね。

それでは皆様、また来年お会いしましょう。

SwiftでURLやファイル拡張子から画像か動画か判別する。

やりたいこと

WKWebViewの

func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)

でタップされたリンクが画像ならAという処理、動画ならBという処理をしたいという事がある。WebViewだけでなく、localのメディアに対するfile://path/to/fileというURLを取得した場合も同様に知りたいし、なんならファイル名だけ(hoge.png)だけからも知りたい。

よくある解決方法

ざっくり調べると拡張子をリストしておいて、それと合致するか調べる方法がよく出てくる。

let imageUrl = URL(string: "file://path/to/file.jpg")!

let imageExtensions = ["png", "jpg", "gif"]
if imageExtensions.contains(imageUrl.pathExtension) {
    print("これは画像です")
}

対応している拡張子が少ない場合や限定したい場合はこの方法が良い。

ただし、上記のコードでは .jpg はマッチするが .jpeg はマッチしないみたいな問題があるのと、「とりあえず画像なら処理Aをしたい」という場合には網羅するのが大変になってくる。

解決方法

UTType を利用すれば良い。

developer.apple.com

import UniformTypeIdentifiers

let movieUrl = URL(string: "file://path/to/file.mov")!

print(UTType(filenameExtension: movieUrl.pathExtension)!.conforms(to: .movie)) // => true
print(UTType(filenameExtension: movieUrl.pathExtension)!.conforms(to: .image)) // => false

let imageUrl = URL(string: "file://path/to/file.jpg")!

print(UTType(filenameExtension: imageUrl.pathExtension)!.conforms(to: .movie)) // => false
print(UTType(filenameExtension: imageUrl.pathExtension)!.conforms(to: .image)) // => true

ちなみに .video というのもあるが、そちらは音声が含まれない動画になる。作る機能によっては .video のみであったり .movie, .video の両方がマッチするように条件文を書く必要がある。

シミュレーターのPHPickerViewControllerでHEIC形式の画像を読み込めない問題の解決方法

環境

起こること

PHPickerViewControllerを利用して画像を取得する時、インターネットに転がってるコードを参考に実装していくとこんな感じになる。

func showPicker() {
    var configuration = PHPickerConfiguration()
    configuration.filter = .any(of: [.images])

    let picker = PHPickerViewController(configuration: configuration)
    picker.delegate = self
    present(picker, animated: true)
}

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    picker.dismiss(animated: true)

    guard let itemProvider = results.first?.itemProvider else {
        return
    }

    let typeChecked = itemProvider.registeredTypeIdentifiers.map { itemProvider.hasItemConformingToTypeIdentifier($0) }
    guard !typeChecked.contains(false) else {
        return
    }
    guard itemProvider.canLoadObject(ofClass: UIImage.self) else {
        return
    }

    itemProvider.loadObject(ofClass: UIImage.self) { object, error in
        let image = object as? UIImage

        DispatchQueue.main.async {
            self.imageView.image = image
        }
    }
}

showPicker() を呼べばPHPickerViewControllerが表示されて、画像を1枚選択できる。その後はコードに書いてある通り UIImage に変換して UIImageView にセットしている。

このコードだとほとんど上手くいくが、シミュレーターで試してみると『ピンク(紫)の花の画像』だけうまく取得できない。

エラーとしてはこんな感じのものが出る

[claims] Upload preparation for claim CA6415EA-8DE0-45D8-A1CD-B090459A2EC7 completed with error: Error Domain=NSCocoaErrorDomain Code=260 "The file “version=1&uuid=CC95F08C-88C3-4012-9D6D-64A413D254B3&mode=current.jpeg” couldn’t be opened because there is no such file."

調査

調べてみると、この画像だけ registeredTypeIdentifiers に "public.heic" も含まれていることがわかる。

print(itemProvider.registeredTypeIdentifiers) // => ["public.jpeg", "public.heic"]

シミュレーターに元々入っている他の画像は ["public.jpeg"] なので、HEICタイプの画像かどうかが影響していそう。

試しにHEIC形式の写真をiPhoneで撮影してシミュレーターに転送してみたところ、こちらも開けなかった。

どうやらHEIC形式の画像の場合、loadObjectが利用できなさそうなので、別の方法でデータを取得する必要がありそう。

対応方法

結論としては、

  • preferredAssetRepresentationMode.current に指定する
  • loadObject ではなく loadFileRepresentation を使う

ことでHEICもJPEGも同じように取得できた。それに対応したコードは下記。

func showPicker() {
    var configuration = PHPickerConfiguration()
    configuration.filter = .any(of: [.images])
    configuration.preferredAssetRepresentationMode = .current // この設定も追加が必要

    let picker = PHPickerViewController(configuration: configuration)
    picker.delegate = self
    present(picker, animated: true)
}

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    picker.dismiss(animated: true)

    guard let itemProvider = results.first?.itemProvider else {
        return
    }

    let typeChecked = itemProvider.registeredTypeIdentifiers.map { itemProvider.hasItemConformingToTypeIdentifier($0) }
    guard !typeChecked.contains(false) else {
        return
    }

    itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { (url, error) in
        guard let url = url else {
            return
        }
        guard let imageData = try? Data(contentsOf: url) else {
            return
        }

        DispatchQueue.main.async {
            self.imageView.image = UIImage(data: imageData)
        }
    }
}

備考

  • 表題通り実機だと問題なく動く
  • シミュレーターでもうまく読み込めるHEICとそうでないのがある
    • 様子を見ていると mode=current.heic から mode=current.jpeg を生成しているが、これが上手く動かないことがあるっぽい