プロダクトデザイナーがSwiftUIでTwitterクライアントを作った
この記事はSwiftUI Advent Calendar 2022の4日目になります。
他のプロのiOSエンジニアの方々の中で素人の私が書くのはなんだか恐れ多いですがまだ空きがあったのと、最近は外部に対するアウトプットもなんだか少ないような気がして勇気を出して書いてみます。
Qiitaで書いてみても良かったのですが単純にZennで書いてみたかったので失礼します...。
はじめに
私@Daxelbookは普段プロダクトデザイナーとして日々生きているのですが、今年の9月くらいから一念発起して実働1ヶ月くらいでSwiftUIでTwitterクライアントを作りました。
今までスタンドアローンのSwiftUIアプリケーションは開発したことはあったのですが、個人的にWebAPIを使ったアプリケーションの開発経験は無かったのでSwiftUIとAPI連携の更なる学習という名目で作ってみました。
(学習のために開発したので公開はする予定は無いです。)
この記事では実際に開発途中に詰まったことを紹介していきます。
開発要件
実際に以下の機能を最低要件として実装しました。
- 任意のTwitterアカウントを認証
- タイムラインの閲覧
- ツイートの詳細情報の閲覧
- ユーザーの詳細情報の閲覧
- フォロワー、フォロー中ユーザーの一覧の閲覧
- 画像や動画の閲覧
- ツイートの投稿
他にも作りたかったのですがこれ以上やると学習の範疇を超えて果てしない個人開発の沼にはまりそうだったので止めました。
(前述にはAPI連携の学習と書きましたが認証までやってしまうと開発期間が途方も無く延びてしまうためTwitterAPIKitを導入しています。活発に開発が続けられており凄く便利なライブラリでした。)
開発途中に詰まったこと
私はコーディング経験が浅いので詰まった回数は数えられないほどありますが、その中でもフレームワークの使用方法という観点で詰まったことを紹介していきます。
1. Coordinatorの扱い
一般的なTwitterクライアントではツイート中のユーザー名やハッシュタグをタップするとそれぞれの情報を表示するために遷移をしますが、SwiftUIでは完全に再現はできませんでした。iOS15になってAttributedStringがリリースされ、Stringの意匠を自由に変えられるようになりましたが、まだタッチイベントを追加できるようにはなってません。
なるべくSwiftUIのAPIを利用して開発したかったのですが、ここは流石に外部ライブラリを使用せざるをえませんでした。ここで使ったのは恐らく有名なActiveLabelを使用しました。これは@や#などを識別して自動的に任意のスタイルやイベントを割り当てられる便利なライブラリです。これをUIViewRepresentableでラップして使用しました。
ですがただライブラリを導入しただけでは意匠は変わるもののタップしてもイベントが発火しませんでした。これには頭を悩ませたのですが、実際はCoordinatorクラスを実装していなかったことに起因するものでした。
struct AttributedText: UIViewRepresentable {
let text: String
let showUser: () -> Void
// タップしたusernameを親のViewに渡すためのBinding
@Binding var username: String
func makeUIView(context: Context) -> UILabel {
// UITapGestureRecognizerのdelegeteに後述のCoordinatorを代入
let labelTap = UITapGestureRecognizer(
target: context.coordinator,
action: #selector(context.coordinator.action(tap:))
)
labelTap.delegate = context.coordinator
let label = ActiveLabel()
label.handleMentionTap { username in
self.username = username
showUser()
}
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
// UILabelをそのままSwiftUIで使えるようにすると文章の長さによって横に見切れてしまうバグのような現象が起きてしまうため、それを解消するためのワークアラウンド的な何かを書いてますが割愛
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
class Coordinator: NSObject, UIGestureRecognizerDelegate {
@objc func action(tap: UITapGestureRecognizer) {}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
}
CoordinatorクラスはUIKit上で起きたイベントの処理SwiftUIに渡してくれるクラスになります。単純にUIViewRepresentableを使えばいいと思っていたのでここは少し詰まりました。とはいえ早くSwiftUIのみで完結するようになってほしいです。
2. NavigationStackの導入
一般的なTwitterクライアントではユーザー名をタップしたらユーザー詳細画面に行って、そのユーザーの過去のツイートをタップしたらツイート詳細画面に遷移するというドリルダウンの構造をとっています。動的に遷移するオブジェクトを決定する振る舞いを実装するのに少し詰まりましたがここはiOS16でリリースされたNavigationStackとNavigationPathで実装しました。詳しくは公式ドキュメントや他のサイトを参照ください。
NavigationPathを使うことで異なる型を持つデータでもナビゲーション上にスタックできるようになりました。
final class Router: ObservableObject {
@Published var path = NavigationPath()
}
このpathはViewをスタックさせるための配列で実際に値を追加や削除することでナビゲーションを操作します。
NavigationStack(path: $router.path) {
ScrollView {
// タイムラインのView
}
.navigationDestination(for: Tweet.self) { tweet in TweetDetailView(tweet: tweet) }
.navigationDestination(for: QuotedTweet.self) { tweet in TweetDetailView(tweet: tweet) }
.navigationDestination(for: User.self) { user in UserDetailView(user: user) }
.navigationDestination(for: CollectionType.self) { type in
switch type {
case .following:
UserCompactCollectionView(id: "", type: .following)
case .followers:
UserCompactCollectionView(id: "", type: .followers)
}
}
}
そして.navigationDestionation()
で任意の型のデータを流しこみ遷移を発火させます。
ただしツイートには通常のツイートと引用ツイートがありどちらもタップするとそれぞれの詳細画面に遷移させたいのでTweet
クラスを継承したQuotedTweet
クラスを実装し型を判別することで異なるViewへの遷移を実現しています。
3. 悩まされたGestureのバグ?
SwiftUIでは@GestureState
や.gesture()
等ジェスチャーのために用意されたAPIを使うことで簡単にジェスチャーによるインタラクションが実装できます。
実際プロトタイプ段階では色々なジェスチャを元にしたUIの実装を予定していました。
ですが私では不可解なバグ(もしくは私の能力が低いので解決できないだけのバグ)がジェスチャーには多く、これらの実装は諦めざるをえませんでした。
4. ScrollViewの仕様
一般的なTwitterクライアントでは新しいツイートを読み込んだ時にスクロールの位置は読み込む前と変わらずに上部に新しいツイートが追加されます。
YouTubeのvideoIDが不正です
この動画では私が投稿したツイートが読み込まれてもスクロールの位置は変わりません。(この例ではTweetbotですが公式Twitterクライアントでも同じ挙動をするはず...)私が試した範囲だとこれをSwiftUIで再現することは出来ませんでした。厳密に言うと近しいものはできました。
それはScrollReader
を使って読み込む前に一番上に表示されてるツイートの位置を記憶し、読み込んだ後に.scrollTo()
で記憶した位置にスクロールするというものです。ですがこの方法では読み込みとスクロールの間に一瞬のちらつきが生じてしまって想定しているものとは違いました。
このように他にも詰まったところは一杯ありますが上に挙げた箇所が特に調査や実装も含め時間がかかりました。とはいえ多くのことを学んだので次は良かったことを紹介します。SwiftUIに関することや開発したこと全体についてのことも合わせて紹介します。
開発して良かったこと
1. SwiftUIについて詳しくなれた
これは見出し通りですね。以前は雰囲気で書いていたのですが、SwiftUIがどういうフレームワークでどう成り立ってるのかについての解像度がグンとあがりました。特に一番学びだったのがSwiftUIがUIKitをベースに成り立っているということです。(iOSエンジニアの方にとっては当たり前なことかもしれませんが...)
次の事項にもつながるのですがUIKitの知識をある程度つけることでSwiftUIに現状できること / できないこと、得意なこと / 苦手なことがより分かるようになりました。
これは余談ですがSwiftUIは開発する初速は凄く早いフレームワークなんだろうなという感想を持ちました。実際開発の後半はいわゆるUIモックを作成せずにそのままコードを書くようになっていてとても開発速度が向上しました。
ですが一方で作り込む時に異常に時間がかかりました。それはドキュメントがまだ充実していなかったり、ベストプラクティスが確立されていなかったり発展途上のフレームワークならではのことなのかもしれませんが、時折これがUIKitだったらこんな悩みないんだろうなぁと思うことが多々ありました。
2. UIKitについて詳しくなれた
私はSwiftUIがリリースされて初めてコードを書き始めた人間なのでUIKitのことは全く知識はありませんでした。そこが自分のコンプレックス?になっていたこともあったんですが、実際「UIKitの知識が無くてもSwiftUIが書ける!」等の言説を方々で見ていたのでそれを信じて書いていたのですが今回それはあまり正しくはないのだなと実感できました。
SwiftUIがUIKitをベースに成り立っているということやまだまだ発展途上のフレームワークである事実に鑑みるとUIKitの一定量の知見は必要だと思いました。
今後必要になるは分かりませんが、これからは何かに詰まった時はUIKitに原因があるのかもと思いを巡らせるようにします。
3. 単純にコードを書くのが少しだけ上手くなった。
今まではスタンドアローンのアプリケーションしか開発経験がなかったので、凄く基本的なプログラムしか書けませんでしたが今回WebAPIとの連携を前提にしたアプリケーションを開発したので非同期処理やエラーハンドリングを各場面が多くあり、凄く勉強になりました。
またツイートを多く表示したり、Viewを重ねたり設計なのでパフォーマンスに悩む部分がありここも今後のUI設計の際の糧になると思います。やはりプラットフォームを根本から理解することは凄く勉強になります。
4. エンジニアに対する畏怖の念が強まった
私は普段プロダクトデザイナーとしてソフトウェアエンジニアの方々と働いてます。そして、個人的な信念として「デザインとエンジニアリングは不可分な物である」という思いを持って働いています。
それでもまだまだエンジニアリングに対する理解が不十分だと常日頃感じていました。ですが今回の開発経験で少しだけですが経験値がアップして解像度が上がり、同時にエンジニアに対する畏怖の念を持つようになりました。
保守しやすく堅牢なコードをあんな速度で書けるなんて本当に凄い...。私には見えてない世界がまだまだ広がっているんだなと思います。これからはもっと丁寧に仕事が出来そうです。
最後に
実際リリースすることのないアプリケーションですが、今後は分かりません。SwiftUIが成熟しより柔軟に実装できるようになったらまた改めてTwitterクライアントに挑戦してみようとおもいます。
私はエンジニアではないのでエンジニアの方々からすると特に注目する内容が無いかもしれませんが、ここまで読んでいただきありがとうございました。
普段はTwitterに生息していますのでもし私に興味があれば覗いてみてください。今は正式にAppStoreにリリースする予定のアプリケーションのゆっーくり開発をしています。
余談1
これを書き終えた時にはちょうどElon MuskによるTwitter社員の大量解雇事件が起きていたのでなんとも変な時に書いてるなと思ったのと同時に、今後APIが非公開になったり何か変更があったりすることを考えるとモチベーションが下がってしまいましたという何とも悲しい結末。
余談2
私は普段プロダクトデザイナーとしてグラフィック制作もやっています。なので今回はアプリケーションのアイコンも含めて制作しました。
我ながらかわいいアイコンが出来たなと思います。かわいい
Discussion
自分もTwitterアプリをSwiftUIで作っていたのですが、同じ問題に当たったので、解決できた部分も載せておきます。
ユーザー名、ハッシュタグのタップは.environmentのopenURLを自分で設定することでタップアクションを変更できるようです。
https://zenn.dev/zunda_pixel/articles/6391e78788582a
https://github.com/zunda-pixel/Tuna/blob/7ab613cb1fae11e140b01fc7c3ecbe27c4583ba4/Sources/View/BaseView/LinkableText.swift#L102
NavigationStack/ NavigationPathは便利なので、SwiftUIだと必須に感じました。
Swipe to Openも自分も諦めて、タップで遷移するようにしました。
ListだとswipeActionsでSwipe to Openは簡単なのですが、List内のViewが勝手に、見た目を変えられてしまう。などの問題でListを採用できず、Swipe to Openは諦めました。
ScrollViewのスクロール位置に関しても、自分も諦めました。
アプリのデザインが可愛くて好きです!
コメントありがとうございます!
openURLで制御するとどうしてもURLの設定が必要になってくるので結構悩ましかったです...
なので私はTimelineのViewでは外部リンクのURLとユーザー情報以外の箇所をタップするとツイート詳細のViewに遷移させるようにしました。意匠についてもAttributedStringで太字や文字色を変更させるだけにしました。
そしてツイート詳細側ではActiveLabelでメンションされたユーザーの詳細画面への遷移を実現しています。
Swipe to ActionとかScrollViewは本当に悩みましたw
SwiftUI完璧にやるにはまだ難しいですよね。
これはTwitterのリンク付けと、ActiveLabelのリンク付けの問題なのですが、
Twitterだと
こんにちはhttps://google.comこんにちは
という文字列はhttps://google.com
にリンク付けがされるのですが、ActiveLabelだとリンク付けされません。URLと認識するために、両端にスペースが入った
こんにちは https://google.com こんにちは
では大丈夫です。さらにこんにちは https://google.comこんにちは
という文字列はhttps://google.comこんにちは
という文字列がURLとして認識されてしまいます。なので完璧にツイートを表示させたい場合、Twitter APIからのレスポンスに何のリンクが紐付けされているかが示されているので、そこからAttributedStringを設定する必要があります。(Twitter API v1でもできたかな?)
新規ツイート投稿画面はそのAPIからのレスポンスが利用できないので、自前実装が必要ですが、自分は完璧な実装は諦めました...
テスト
こんにちはhttps://google.comこんにちは(
Twitterはhttps://google.comにリンク付可能、Zennや他の多くのサービスなどはリンク付不可能
)こんにちは https://google.comこんにちは(
Twitterだとhttps://google.comのみリンク付、Zennや他の多くのサービスなどはこんにちはもリンクに含む
)