文字っぽいの。

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

吉祥寺散策

吉祥寺を散策してきたのでその記録。

井の頭公園

ブルースカイコーヒーのねこドーナツとケーニッヒのホットドッグとフランクフルト。

井の頭公園は結構人がいた。あひるさんボートも人気で大行列だった。結構暖かい日だったので、ボート乗るのも気持ちよさそうだったけど流石に混みすぎててやめておいた。

四歩

古道具屋と日用雑貨屋とカフェが一緒になったお店、四歩(しっぽ)へ

売っている雑貨もいい感じだし、店内も落ち着いていて良かった。 いちごのミニパフェといちごのアールグレイティーを注文。いちご祭り。ミニパフェ、ちょうどいい量だし中のアイスも卵感の強いミルクセーキ的な味で美味しかった。甘いものは大量に食べられないので、ミニパフェがあるの助かる。

ダパイダン105

夜はハモニカ横丁へ。すごい久しぶりに来たので、知ってる店がなくなってる。

一軒目はダパイダン105へ。焼き小籠包が有名なお店。

焼き小籠包は「小皿にとって、スープを出してから食べてね」と最初に注意される。それをしてもスープが飛びがちなので、紙エプロンはつけておいたほうが吉。

ザーサイが食べ放題なのも嬉しい。

立ち寿司横丁

締めに寿司。

生さばがあって、脂がのってて美味かった。飲んだあとのあさり汁も美味しい。

星のカービィ スーパーデラックス ドットライトを買った。

めちゃくちゃかわいい。

電源はUSB-Cなので、コンセント経由でもモバイルバッテリー経由でもつながれば光る。でも光ってなくても存在感があってかわいい。

近くで見ると「溝が掘ってあるだけだな」という感じだけど、ちょっとはなれるといい感じにドット感を感じるようになっている。趣味のインテリアとしてちょうどよい。

プレミアムバンダイで買える。

p-bandai.jp

SwiftUIの `.contentTransition(.numericText())` で遊ぶ

iOS 17+で使えるSwiftUI用のAPI.contentTransition(.numericText()) というのがある。

使い方は簡単で

Text("\(value)")
    .contentTransition(.numericText(value: value))

こうやって書けば、Textの中身が変わる時にアニメーションしてくれる。withAnimation {} 経由でStateは変えないといけないことに注意。

試してみる

でっかい乱数を生成して4桁ずつスペースで区切って表示する。

struct ContentView: View {
    @State private var number: Int = 0

    var body: some View {
        VStack {
            Text(format(number: number))
                .font(Font(UIFont.monospacedDigitSystemFont(ofSize: 32, weight: .bold)))
                .contentTransition(.numericText(countsDown: true))
            Button("Random") {
                withAnimation {
                    number = Int.random(in: 1...10000000000000000)
                }
            }
            .buttonStyle(.borderedProminent)
        }
    }

    func format(number: Int) -> String {
        let formatter = NumberFormatter()

        formatter.groupingSeparator = " "
        formatter.groupingSize = 4
        formatter.usesGroupingSeparator = true
        formatter.minimumIntegerDigits = 16

        return formatter.string(from: NSNumber(value: number)) ?? ""
    }
}

実用性がある感じで試す

カウンターを実装すれば早いけど、すぐできちゃうので別の実装をしてみる。

struct CreditCardView: View {
    @State private var cardNumber: String = "4111 1111 1111 1111"
    @State private var show: Bool = false

    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 8)
                .fill(Color(.lightGray).shadow(.drop(color: .black.opacity(0.2), radius: 4, x: 0, y: 0)))
                .stroke(Color(.border), style: StrokeStyle(lineWidth: 1))

            HStack {
                HStack(spacing: 16) {
                    Image(systemName: "creditcard")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 20)
                        .foregroundStyle(Color(.border))

                    Text(cardNumber)
                        .font(Font(UIFont.monospacedSystemFont(ofSize: 16, weight: .bold)))
                        .contentTransition(.numericText(countsDown: show))
                }

                Spacer()
                Button(action: {
                    withAnimation {
                        show.toggle()

                        if show {
                            cardNumber = "4111 1111 1111 1111"
                        } else {
                            cardNumber = "**** **** **** 1111"
                        }
                    }
                }, label: {
                    if show {
                        Image(systemName: "eye.fill")
                            .resizable()
                            .scaledToFit()
                            .frame(width: 20)
                            .foregroundStyle(Color.gray)
                    } else {
                        Image(systemName: "eye.slash.fill")
                            .resizable()
                            .scaledToFit()
                            .frame(width: 20)
                            .foregroundStyle(Color.gray)
                    }
                })
            }
            .padding([.leading, .trailing], 16)
        }
        .frame(height: 60)
        .padding([.leading, .trailing], 16)
    }
}

これを実行するとこうなる

いい感じにアニメーションする処理が簡単なコードでかけてめでたい。

.numericText(countsDown: show) でshowの値をみることで下がるアニメーションと上がるアニメーションをトグルできるようにしているので、隠す時と表示する時で対応した動きができるようになっている。

RICOH GR IIIx を買った。

買ったもの

RICOH GR IIIxを買った。

いわゆるコンデジ

撮った写真

プログラムオートやネガフィルムフィルタで撮った写真たち。全部現像せずにJPEG撮って出ししたものを、ブログ用に縮小だけしている。

良い点

  • とにかく小さいのが良い。ショルダーバッグにも入れられるので、気楽に持ち運べる。
  • 撮って出しでいい感じの写真が撮れる。フィルターもいい感じ。
  • 起動が早い。パッとカバンから取り出してすぐに撮れる。
  • ガチ感が薄いので飲食店で撮影してても恥ずかしくない。
  • スマホと通信できるので、撮ってすぐ写真をスマホに転送してSNSに投げられる。
  • 画面をタップしたらそこにフォーカスを合わせるという「こうしたら、こう動いて欲しい」という動きがちゃんとできる。

微妙な点

  • 新品も中古も全然在庫がない。値崩れもしてない。
  • 新品で買う場合は基本入荷待ちになるので2ヶ月ほど待つ。
  • カメラとスマホを接続するための公式iOSアプリの出来が厳しい。
    • 個人開発のアプリがいくつかあるけどちょっと値段が高め。
  • 理解するまで設定画面の使い方・挙動が難しい。

総評

買ってよかった。ちょっとしたお出かけでも常に持ち運んでいる。「1日中観光して写真を撮りまくる」という日でもなければ電池も余裕で持つ。とにかく撮れる画がいい。

SwiftUI Introspect経由でDelegateを設定するとSwiftUIのBindingが死ぬ

結論

わからん。誰か詳しい人にどういう挙動が起きているのか教えてほしい。対応策を教えてもらえるともっと嬉しい。

書いてみたコード

画面にはTextEditorとその文字をクリアするButtonしかない非常にシンプルなもの。

import SwiftUI
@_spi(Advanced) import SwiftUIIntrospect

struct ContentView: View {
    @State var text: String = ""

    @Weak var textView: UITextView? {
        didSet {
            textView?.delegate = delegate
        }
    }

    var delegate = Delegate()

    final class Delegate: NSObject, UITextViewDelegate {
        func textViewDidChange(_ textView: UITextView) {
            print("Delegate:", textView.text)
        }
    }

    var body: some View {
        VStack {
            TextEditor(text: $text)
                .onChange(of: text, {
                    print(text)
                })
                .introspect(.textEditor, on: .iOS(.v16, .v17)){ textView in
                    self.textView = textView
                }
            Button {
                text = ""
            } label: {
                Text("文字クリア")
            }
        }
        .padding()
    }
}

起きること

textView?.delegate = delegateDelegateを渡すとしっかりと func textViewDidChange(_ textView: UITextView) が紐づいてそちらの処理は発火する。一方で、 .onChange(of: text) とButton内の text = "" は発火しなくなる。当然ながらtextView?.delegate = delegateコメントアウトしてやれば、.onChange(of: text)text = ""によるクリアも正しく動作する。

TextEditorの裏側にはUITextViewがおり、そのUITextViewDelegateを奪ってしまうから発火しないのだろうという推測はできるのだが……。

super. 的なコードを書いてどちらも発火させることはできないんだろうか? UITextViewDelegateがつなぎ込めてしまえば実装上は実現できるけれど、なんかね。

2023年の買ってよかったもの。

毎年12月になって慌てて書いているので、今のうちに書いておこう。

山崎実業 バスルーム多機能ラック

マグネットでお風呂場の壁にくっつけられるラック。 シャンプー、ボディーソープ、シェービングクリーム、体洗うタオルなどの一式をこれにセットすることができて便利。マグネットはかなり強力なので、このラックの上でシャンプーなどのポンプを押してもびくともしない。

Anker PowerLine Ⅲ

PD(高速)充電にも対応しているケーブル。やわらかくてコシがあってサラサラしている。とにかく絡まない。バッグの中に入れて持ち運ぶ用途で非常に便利に使っている。

エレコム クリーナー ブラシ

帯電防止のクリーナーブラシ、ディスプレイのホコリを取るのに使っている。これを買うまではキムワイプなどで頑張っていたけど、これを買ったらこれで良くなった。洗えるのも助かる。

HHKB Professional HYBRID Type-S 英語配列/雪

HHKB Pro2の墨を使い続けてきたけど、無線にしたくなったので購入。詳しくはこちら。

fromatom.hatenablog.com

銭別銀行とりだし君

小銭を入れると勝手に仕分けしてくれて便利な貯金箱。財布に入っている小銭をとりあえず入れればOKになったのでとても便利。10円がたまりがち。

Mcdodo 多機能 デジタル収納ケース

  • Type-C to Type-Cケーブル(PD充電対応)
  • Type-C to Micro USB変換アダプタ
  • Type-C to Lightning変換アダプタ
  • Type-C to USB-A変換アダプタ
  • SIMカード取り出しピン

が全部入っているのに非常に小さい。とりあえずカバンに入れておけば応急処置で充電したり、通信したりが可能になる。

FUNDAY モイストシェーブ&ウォッシュ

シェービングしつつ洗顔もできるやつ。朝にこれで顔を洗いながら髭を剃っている。パッケージがオレンジで可愛いのも気に入ってる。メンズ用の化粧品って全部青・白・黒・緑なのなんとかならんのか?

Elgato Key Light Mini

顔を明るくするライト。部屋のシーリングライトを背負った位置に机があってどうにも顔が暗くなりがちで、MTGはまだしも面接や外部の方との打合せで印象がよろしくないので購入した。爆裂に眩しい。PCやスマホから電源のON/OFFや光量変更、色温度変更もできてすごい便利。眩しい。

Anker Eufy Security SmartTrack Card

財布に入れられるトラッカー。AppleのAirTagは地味に大きく厚みもあるため、小さい財布を使っていると使えないけど、これなら入る。クレカ2枚分の厚さなので財布に入れるカードを整理して入れた。財布を落としたことは無いけど、もし落としたら大変すぎるので転ばぬ先の杖。

Belkin MagSafe対応 磁気ワイヤレス充電スタンド

くっつければ充電できるスタンド。どうにも「AppleWatchも充電できる!」みたいなのが多くて困っていたけど、これはiPhoneだけ充電できるし、安いし、デザインもへんてこじゃない。一方で重みはないので工夫をしないとiPhoneを取り外すのがちょっと難しい。

Anker 633 Magnetic Wireless Charger

最強のMagSafe充電器。なんと充電器がモバイルバッテリーも兼ねているので、出かける直前で「全然充電ないやん」ってときでも、このバッテリーをつけっぱなしで出かければ良い。しかもスタンドを使うためにはバッテリーをセットしないとなので「バッテリーの充電を忘れていた」という事故も防げる。デメリットは価格。高い。

Maker hart Just Mixer S

Mac, Windows, PS4, Switchなど複数の音源があり、それを1箇所に集めたい時に便利なミキサー。小型かつ安価なので非常に良かった。

Cubilux マルチ イヤホン スプリッター

Maker hart Just Mixer Sで集めた音源を、複数の出力に分割するために購入。スピーカー、イヤホン、ブルートゥーストランスミッターに音を流している。

オーム電機 キッチンライト

キッチンのコンロまわりがどうにも暗くて購入。換気扇についてる申し訳程度のライトでは全然ダメだったけど、これをつけたらバリバリに明るくなった。

サンスター 水まわり用輝き洗剤キーラ

これをスポンジに付けてシンクを磨くだけでピカピカになって最高。水垢がひどい場合は塗ったあとでしばらくおいておくと良い。2週間に1回程度使ってシンクをきれいにしてニッコリしている。

リーベックス(Revex) ワイヤレス チャイム

インターホンが隣の部屋にあるかつ、音がちょっと低めなためにドアを貫通してくれない。なおかつ仕事中はイヤホンやヘッドホンをして音楽を流しているので更に気づけなくなる。iPhoneの機能もあるけど、なかなかそれも使いにくくて困ってた。これなら遠隔で音がなるだけでなく、光ってくれるのでめっちゃ気付ける。

デメリットとしてはドアの近くにインターホンがあって、勢いよくドアを閉めるとその音で反応してしまうこと。

Xbox ワイヤレス コントローラー

PCゲーをやるために購入。ずっとPS4DUALSHOCKを使っていたけど、スティックが加水分解されてきてしまったので買ってみた。めちゃくちゃ良い。背面にドット加工がされていることによってちょうどよい滑り止めになっていて、手にすごい馴染む。十字キー部分はクリック感があってカチカチを音がなりながら押せるもの。これが存外に良くて、メニュー操作をするときなどあまりにも快適。乾電池式なのも良くて、たまに充電池を入れ替えれば良いだけなのが良い。Aボタンが下にあるのが人によってはデメリットだけど、SteamもPS5も下にあるボタンが決定になってるから、こちらに慣れていくのが良いんだとおもう。

FPSなどをガチでやる人ならホールエフェクトとか背面ボタンとか欲しいので物足りないと思うけど、そうじゃないならこれが最適解だと思う。

コンドー お薬手帳&カードケース

カバンの中に「財布に入れる1軍ではないが、持ち歩いておくと何かと便利」なカードとお薬手帳を入れているのだけど、それらをまとめるのに便利。

Xiaomi Smart Band 7

最強スマートバンド。自分がスマートバンドに求めているものは、時間が分かるのと通知が来ること、そして充電を頻繁にしなくて良いこと。あとはたまーに、タイマーやストップウォッチが使えればそれで良い。

XiaomiのSmart Bandシリーズは2週間バッテリーが持つのに有機ELで、なおかつセール時なら4000〜5000円くらいで買える。なんかおかしい。文字盤もカスタマイズできるので、テンプレートをカスタマイズしたり、自分で作ったりして楽しめる。通知も「このアプリの通知のみ送る」という設定ができるのが非常に便利。

タオル研究所 ミニバスタオル

タオルはタオル研究所が最強。ふわふわだし、毛羽落ちも少ない。自分は髪が短いのででっかいバスタオルより、こういうミニバスタオルの方が、洗濯や乾燥が楽になって良い。

GUARIRE 肘置きクッション

どうにも肘置きに肘を立てている事が多いらしく、肘の骨が痛くなってきたので購入した。「ちゃんと固定できるんかな?」と不安だったけど、ガッチリと固定されてずれることもないし、肘の骨も痛くならなくなったので最高。おまけでリストレストがついてくるけど、これはいらない。

マーナ 極しゃもじ

全くくっつかないしゃもじ。しかも薄いから米を切るように混ぜるのがとても容易にできる。注意点としては食洗機に入れてはいけないこと。食洗機で洗うとすぐにくっつくようになってしまう。手洗いなら大丈夫。

3coins バスタオルハンガー

https://www.palcloset.jp/display/item/2006-20H2149-00/

画像だと複数枚干しているけど、これに1枚のバスタオルを干している。そうするとバスタオル同士がくっつかずに隙間があくので乾きやすいし臭くなりにくい。非常に便利だけど、3coinsなので耐久性はちょっと弱い。一回割れてしまったので買い直した。

象印 加湿器 EE-DD50

自分が買ったのはEE-DC50という1つ前のモデル。最強の加湿器。お湯を沸かすのでどうしても電気代はかかるが、喉を痛めたり皮膚乾燥のかゆみで掻き壊して病院に行くよりは良いと思う。

Swift OpenAPI GeneratorでISO8601拡張形式のDateが処理できないことがある

問題

Apple公式のOpenAPI Generatorを使うと、Responseをパーズできなくて下記のエラーが出ることがある。

Client error - cause description: 'Unknown', underlying error: DecodingError: dataCorrupted - at : Expected date string to be ISO8601-formatted.

原因

正確にはSwift OpenAPI Generatorではなくswift-openapi-runtimeがデフォルトで利用するISO8601DateTranscoderの実装に起因している。

public struct ISO8601DateTranscoder: DateTranscoder {

    /// Creates and returns an ISO 8601 formatted string representation of the specified date.
    public func encode(_ date: Date) throws -> String { ISO8601DateFormatter().string(from: date) }

    /// Creates and returns a date object from the specified ISO 8601 formatted string representation.
    public func decode(_ dateString: String) throws -> Date {
        guard let date = ISO8601DateFormatter().date(from: dateString) else {
            throw DecodingError.dataCorrupted(
                .init(codingPath: [], debugDescription: "Expected date string to be ISO8601-formatted.")
            )
        }
        return date
    }
}

https://github.com/apple/swift-openapi-runtime/blob/d50b48957ccb388fb89db98a56c2337276298e79/Sources/OpenAPIRuntime/Conversion/Configuration.swift#L28-L42

SwiftのISO8601DateFormatterを利用しているので良いように見えるが、ISO8601には拡張形式がありこのコードはそれに対応していない。

例えば、 2023-11-23T10:29:15.53+09:00 のように、ミリ秒まで含まれるISO8601拡張形式を処理したい場合は

let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFractionalSeconds]

のように .withFractionalSeconds オプションを追加で指定する必要がある。他にも拡張形式は様々あるため、拡張形式でDateが表記されているレスポンスの場合、ISO8601DateTranscoderではうまく処理できないことがある。

対策

DateTranscoder はprotocolとして公開されているので、自分で DateTranscoder を作ってしまえば良い。

struct CustomISO8601DateTranscoder: DateTranscoder {
    public func encode(_ date: Date) throws -> String { ISO8601DateFormatter().string(from: date) }

    public func decode(_ dateString: String) throws -> Date {
        let iso8601DateFormatter = ISO8601DateFormatter()
        iso8601DateFormatter.formatOptions = [.withFractionalSeconds]

        guard let date = iso8601DateFormatter.date(from: dateString) else {
            throw DecodingError.dataCorrupted(
                .init(codingPath: [], debugDescription: "Expected date string to be ISO8601-formatted.")
            )
        }
        return date
    }
}

実装した CustomISO8601DateTranscoder はConfiguration経由でClientに渡せば良い。

let transport = AsyncHTTPClientTransport(configuration: .init())
let client = Client(
    serverURL: serverURL,
    configuration: Configuration(dateTranscoder: CustomISO8601DateTranscoder()),
    transport: transport
)

iso8601DateFormatter.formatOptions にどのOptionを指定すればよいかはAPIのレスポンス形式による。使えるOptionはこちらを参照すること。

developer.apple.com

参考ページ