[SwiftUI] 流れるテキスト(TickerText ) を作ってみる

SwiftUI2021

     
⌛️ 3 min.
よく電光掲示板等でみる 流れる Text を SwiftUI の View として作ってみます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS14.3
  • Xcode 15.3 beta
  • iOS 17.4 beta
  • Swift 5.9

完成図

以下のような 動作をする View を作りたいということです。

以下では、macOS 上の画像で説明していますが、iOS でも同じ動作です。

設計っぽいこと と 実装

テキスト(文字列)を表示することは、SwiftUI の Text を使用することで簡単にできます。

難しそうなのは、時間に応じて、文字の表示を変更することです。

まずは、時間に応じて移動させることを考えてみます。

時間に応じて移動する Text

時間自体は、Timer というクラスが用意されていますので、そのまま使います。

文字の表示を移動する = 文字を表示する位置をずらす + 指定領域以外非表示にする

という形で問題を分割するとゴールが少し近くなった気がします。

最初の “文字を表示する移動する” を考えます。

文字を表示する位置をずらす

SwiftUI には、.offset という ViewModifier が用意されています。


参考
offsetApple Developer Documentation

この View Modifier を使用すると、本来表示されるべき位置から “ずらした位置” に表示することができます。

例えば、Text(“Hello, world!”) を以下のように、offset して表示してみます。
オフセット前の表示位置は、青い矩形で表示されています。(青い矩形の位置が、本来(?)の位置ということです)
オフセット後の表示位置は、赤い矩形で表示されています。(赤い矩形の位置が、offset された位置ということです)

struct ContentView: View {
    var body: some View {
    var body: some View {
        Text("Hello, world!")
            .font(.largeTitle)
            .border(.red)
            .offset(x: -30)
            .border(.blue)
    }
}
ViewOffset

青い矩形(本来の描画位置)に対して 赤い矩形が左にずれているのがわかります。”Hello, world!” の文字は、本来の位置である青い矩形からすこしずれて表示されています。
これは、.offset(x: -30) という View Modifier が指定されたことで、Text が本来の青い矩形位置に表示されるかわりに、X方向に -30 ずらされた 赤い矩形内に表示されているということです。

この offset を使って、文字の表示位置を変更していきます。

時間に応じて offset する

次に 時間に応じて動かす を考えます。

先に説明した offset を使うことを考えると、時間に応じて、offset 値を変化させることができれば OK のハズです。

時間に応じて変化させるタイミングは、Timer.TimerPublisher を使用します。


参考
Timer.TimerPublisherApple Developer Documentation

指定時間毎に、publish されるので、onReceive で受け取ります。

参考
onReceiveApple Developer Documentation

struct ContentView: View {
    @State private var offsetValue: CGFloat = 0

    var body: some View {
        Text("Hello, world!")
            .font(.largeTitle)
            .border(.red)
            .offset(x: offsetValue)
            .border(.blue)
            .onReceive(Timer.publish(every: 0.2, on: .main, in: .default).autoconnect()) { newDate in // 0.2 秒毎に移動
                self.offsetValue -= 2 // 2 づつ移動
            }
    }
    
    
}

以下のような動作になりました。

先ほどの border 指定を残したまま動かしているので、ずれていく様子がわかります。

青い矩形内だけが 表示されるようになった場合を想像すると、”Hello, world!” が少し左に流れた状態と見ることもできます。
このように “Hello, world!” を offset をすこしずつ変えて表示していくことで、流れるように見えるようになるハズです。

ただし、offset が大きくなった時に 文字が大きく左にずれて表示され、枠内に表示されなくなってしまうことは問題です。
右から再度現れるのが、一般的な期待値な気がしますが、そのあたりは、もう少ししてから解決していきます。

領域以外 描画しない

まずは、青い矩形内のみ表示することを考えます。

実は、これは簡単です。

SwiftUI には、.clipped という ViewModifier が用意されています。


参考
clippedApple Developer Documentation

これは、View の Bounding Box 以外を描画しないという View Modifier です。

ですので、この ViewModifier を指定することで “領域以外は、描画しない” は実現できます。

先ほどの Text に .clipped をつけてみます。

struct ContentView: View {
    @State private var offsetValue: CGFloat = 0
    
    var body: some View {
        Text("Hello, world!")
            .font(.largeTitle)
            .border(.red)
            .offset(x: offsetValue)
            .border(.blue)
            .onReceive(Timer.publish(every: 0.2, on: .main, in: .default).autoconnect()) { newDate in
                self.offsetValue -= 2
            }
            .clipped()
    }
}

以下のような動作になります。

Text が本来持つ青い枠外部の描画はされないことがわかります。

繰り返される Text

“左から消えたテキストが 右から現れる” を実現することが次の課題です。

きちんと(?) 考えると、左から消えた文字を文字列の右側に追加して・・・・ と考えなければならず、問題がより複雑化します。

無限にテキストを繰り返す

少し視点を変えてみると、
十分に繰り返された(例えば50回繰り返された) テキストがあれば、”しばらくは” 左から消えた文字が右から現れた “ように” 見えることに気づきます。

実際にそう見えるかやってみます。

struct ContentView: View {
    @State private var offsetValue: CGFloat = 0
    @State private var start = false
    
    let text = Array(repeating: "Hello, world!", count: 50).joined()
    
    var body: some View {
        Text(text)
            .lineLimit(1).fixedSize()                           // 強制的に1行に省略なしで表示
            .frame(width: 137, alignment: .leading)    // "Hello world!" の幅は、137です。(測りました)
            .font(.largeTitle)
            .border(.red)
            .offset(x: offsetValue)
            .border(.blue)
            .onReceive(Timer.publish(every: 0.2, on: .main, in: .default).autoconnect()) { newDate in
                self.offsetValue -= 2
            }
            .clipped()
    }
    
    
}

world! のあとの Hello が続けて現れて、あたかも 左に消えた文字が右から現れているように見えます。

それっぽく見えることは確認できました。しかし、”しばらくは”と注記したとおり、しばらく待つと表示がなくなります。
具体的には、繰り返したテキストが尽きれば、表示はなくなります。

次の問題は、「何回繰り返しておけば良いか」ということになります。

答えは、「何回繰り返すかを事前に決めることはできない」です。移動は表示する時間に応じて大きくなっていき、表示時間は、予測できません。ですので、あらかじめ X回繰り返しておけば十分 ということは言えません・・・

ここでも視点を変えて、「十分に左まで移動させたら、移動分を0に戻す」という方向性に切り替えます。

具体的には、左への移動量が テキストの幅分になったら、移動量を 0 に戻す ということです。
# 移動量は、.offset に与えている値のことです。
以下ではテストとして、テキストは2回繰り返しているものを使用しています。

struct ContentView: View {
    @State private var offsetValue: CGFloat = 0
    
    let text = Array(repeating: "Hello, world!", count: 2).joined()
    
    var body: some View {
        VStack {
        Text(text)
            .lineLimit(1).fixedSize()
            .frame(width: 137, alignment: .leading)
            .font(.largeTitle)
            .border(.red)
            .offset(x: offsetValue)
            .border(.blue)
            .onReceive(Timer.publish(every: 0.2, on: .main, in: .default).autoconnect()) { newDate in
                self.offsetValue = CGFloat(Int(self.offsetValue - 2)  % 137)
            }
            .clipped()
        Text(text)
            .lineLimit(1).fixedSize()
            .frame(width: 137, alignment: .leading)
            .font(.largeTitle)
            .border(.red)
            .offset(x: offsetValue)
            .border(.blue)
            .onReceive(Timer.publish(every: 0.2, on: .main, in: .default).autoconnect()) { newDate in
                self.offsetValue = CGFloat(Int(self.offsetValue - 2)  % 137)
            }
            //.clipped() // -- コメントアウトしてます
        }
    }
}

以下のような動作になり、期待した動作に見えます。

なお、下側に、clipped していないものも表示しているので、背後で動いているロジックがよりわかります。

ここまでの実装で、テキストが絶えることなく流れる表示 のベースができたということになります。

テキストの幅を考慮する

ここまでの例では、テキストの幅がわかっている前提で 試してきました。
具体的には、”Hello, world!”のテキスト幅が 137 であることを前提にコードを書いていました。

テキスト幅に応じてオフセット量をチェックして、繰り返し処理を行なっているため、テキスト幅がわかることは大切です。

137 は、.largeTitle 指定した “Hello, world!” の幅なので、テキストが事前にわかっていれば計測して実装することもできますが、面倒ですし、汎用性もありません。

ということで、指定した文字列を表示したときの Text の幅を取得する方法を考えます。

実は、SwiftUI には、描画の情報を取得するための仕組みがすでに用意されています。GeometryReader です。


参考
GeometryReaderApple Developer Documentation

また、下位の View からの情報を上位 View に受け渡すために Preference という仕組みも用意されています。
実際には、Preference を設定する.preference という View Modifier と Preference の変更に応じて動作する .onPreferenceChange という View Modifier の2つを使用します。


参考
preferenceApple Developer Documentation


参考
onPreferenceChangeApple Developer Documentation

なお、幅の計測対象は、複数回繰り返したテキストではなく、オリジナルのテキストである必要があります。
ですが、オリジナルのテキスト自体を表示することはしたくないので、幅を測るけれども 表示しない という扱いにする必要があります。

SwiftUI には、View を非表示にする View Modifier として、.hidden という View Modifier が用意されていますので、使用していきます。


参考
hiddenApple Developer Documentation

そして、実際の表示は、hidden を使って 非表示にした Text 上に .overlay を使用して、表示することにします。


参考
overlayApple Developer Documentation

上記を組み合わせて 以下のような実装にしました。

struct ViewSizePreferenceKey: PreferenceKey {
    typealias Value = CGSize
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

struct ContentView: View {
    @StateObject var viewModel = ViewModel()
    @State private var offsetValue: CGFloat = 0
    @State private var textWidth: CGFloat = 10
    
    let text = "Hello, world!"
    
    var body: some View {
        Text(text)
            .lineLimit(1).fixedSize()
            .font(.largeTitle)
            .background {
                GeometryReader { geomProxy in
                    Color.clear
                        .preference(key: ViewSizePreferenceKey.self, value:  geomProxy.size)
                }
            }
            .hidden()
            .border(.red)
            .onPreferenceChange(ViewSizePreferenceKey.self, perform: { newSize in
                textWidth = newSize.width
            })
            .overlay(alignment: .leading) {
                Text(text+text)
                    .lineLimit(1).fixedSize()
                    .font(.largeTitle)
                    .offset(x: offsetValue)
                    .border(.blue)
                    .onReceive(Timer.publish(every: 0.2, on: .main, in: .default).autoconnect()) { newDate in
                        self.offsetValue = CGFloat(Int(self.offsetValue - 2) % Int(textWidth))
                    }
            }
            .clipped()
    }
}

以下のような動作になります。

なお、.animation 指定するとスムーズに動作させることもできますが、ここでは、レトロ(?)っぽいままにしておきます。

View として切り出す

再利用しやすいように、別の View として切り出してみます。

外部から与えたい情報としては、以下でしょうか。
・文字列
・移動タイミング(何秒毎に動くか)
・移動量(offset の移動量はどうか)

使用する時は以下のようなイメージです。

    var body: some View {
        TickerText(text: "Hello, world!", slideFrequency: 0.2, slideSize: -2)
            .font(.largeTitle)
    }

フォントのサイズについては、外部から指定しても有効のハズなので、必要に応じて外部から View Modifier で指定してもらいます。

largeTitle や footnote を指定すると、以下のようになることが期待です。

    var body: some View {
        VStack {
            TickerText(text: "Hello, world!", slideFrequency: 0.2, slideSize: -2)
                .font(.largeTitle)
            TickerText(text: "Hello, world!", slideFrequency: 0.2, slideSize: -2)
                .font(.footnote)
        }
    }

個別 View に切り出すこと自体は、@State 等の View 自身の動作用変数と、外部から指定される情報の変数を定義すればあとは簡単です。

struct TickerText: View {
    @State private var offsetValue: CGFloat = 0
    @State private var textWidth: CGFloat = 10

    let text: String
    let slideFrequency: CGFloat
    let slideSize: CGFloat
    var body: some View {
        Text(text)
            .lineLimit(1).fixedSize()
            .background {
                GeometryReader { geomProxy in
                    Color.clear
                        .preference(key: ViewSizePreferenceKey.self, value:  geomProxy.size)
                }
            }
            .hidden()
            .onPreferenceChange(ViewSizePreferenceKey.self, perform: { newSize in
                textWidth = newSize.width
            })
            .overlay(alignment: .leading) {
                Text(text+text)
                    .lineLimit(1).fixedSize()
                    .offset(x: offsetValue)
                    .onReceive(Timer.publish(every: slideFrequency, on: .main, in: .default).autoconnect()) { newDate in
                        self.offsetValue = CGFloat(Int(self.offsetValue - slideSize) % Int(textWidth))
                    }
            }
            .clipped()
    }
}

背景(background)を指定すると、記事当初のような View を作成できることがわかります。

電光掲示板っぽいフォントで表示してみると楽しいです。

上記の動画は、フォント作成工房(http://fontlab.web.fc2.com/) さんのフォントを使っています。

まとめ

SwiftUI で TickerText を作る 方法を説明しました。

SwiftUI で TickerText を作る
  • offset を使用することで、表示位置を調整できる
  • Timer.TimePublisher を使用することで、時間経過に応じた制御ができる
  • Preference を使用すると 子 View から 親 View へ情報を渡せる

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です