🦋
SwiftUI: iOSの電卓みたいなテキスト表示を実装する
iOSに標準でついている電卓をよく観察すると、画面の幅よりも数式が長くなってくると両端にフェードが入ることがわかります。

さらによく観察すると以下のような仕様であることがわかります。
- 数式が画面の幅より短い時
- スクロールしない
- フェードしない
- 数式が画面の幅より長い時
- スクロールする
- 数式の左右が表示領域外にはみ出す時に限りフェードする
- 数式の文字列に変更があった場合は末尾が表示されるように自動スクロールされる
この仕様を満たすテキスト表示をSwiftUIで実装してみましょう。
実装の概要
-
ScrollViewにTextを入れつつ、そのTextのbackgroundにGeometryReaderを入れることでTextの位置やサイズの情報を取得する - 位置の座標は親の
ScrollViewに対するTextの相対座標なため、coordinateSpace(name:)と.frame(in: .named(_:))を用いる -
GeometryReaderを用いて取得できる情報をScrollViewの外側で使うためにPreferenceKeyとonPreferenceChange(_:perform:)を用いる - フェードが必要かどうかは表示領域の幅と
Textの相対座標から判別する - フェードは
.mask()とLinearGradientを用いて実現する -
ViewをScrollViewに入れつつも、スクロールする必要がないほどViewが小さい場合にスクロールをさせたくない場合は.scrollBounceBehavior(.basedOnSize)を用いる - 文字列に変更があった際に自動で末尾へスクロールするために
.id()と.onChange(of:)、ScrollViewProxy.scrollTo(_:anchor:)を用いる
実装
ScrollableText
struct ScrollableText: View {
var width: CGFloat
@Binding var value: String
@State private var scrollPlace = ScrollPlace.fit
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
Text(value.isEmpty ? " " : value)
.font(.title)
.fontWeight(.bold)
.fontDesign(.monospaced)
.lineLimit(1)
.frame(minWidth: width, alignment: .trailing)
.id("indicator")
.background {
GeometryReader { proxy in
Color.clear.preference(
key: ScrollPlacePreferenceKey.self,
value: ScrollPlace(width: width, frame: proxy.frame(in: .named("scroll")))
)
}
}
}
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
.frame(width: width)
.mask(scrollPlace.mask)
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollPlacePreferenceKey.self) { value in
scrollPlace = value
}
.onChange(of: value) { _, newValue in
proxy.scrollTo("indicator", anchor: .trailing)
}
}
}
}
ScrollPlacePreferenceKey
struct ScrollPlacePreferenceKey: PreferenceKey {
typealias Value = ScrollPlace
static let defaultValue: Value = .fit
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
ScrollPlace
enum ScrollPlace: Equatable {
case fit
case start
case between
case end
init(width: CGFloat, frame: CGRect) {
self = if width == frame.width {
.fit
} else if frame.minX.rounded() > -5 {
.start
} else if frame.maxX.rounded() < width + 5 {
.end
} else {
.between
}
}
}
extension ScrollPlace {
var stops: [Gradient.Stop] {
switch self {
case .fit:
[]
case .start:
[
.init(color: .black, location: 0.0),
.init(color: .black, location: 0.8),
.init(color: .clear, location: 1.0),
]
case .between:
[
.init(color: .clear, location: 0.0),
.init(color: .black, location: 0.2),
.init(color: .black, location: 0.8),
.init(color: .clear, location: 1.0),
]
case .end:
[
.init(color: .clear, location: 0.0),
.init(color: .black, location: 0.2),
.init(color: .black, location: 1.0),
]
}
}
@ViewBuilder
var mask: some View {
switch self {
case .fit:
Color.black
case .start, .between, .end:
LinearGradient(stops: stops, startPoint: .leading, endPoint: .trailing)
}
}
}
ContentView
struct ContentView: View {
@State var value: String = "0"
var body: some View {
VStack(spacing: 20) {
Spacer()
ScrollableText(width: 200, value: $value)
TextField("", text: $value)
.multilineTextAlignment(.trailing)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.frame(width: 200)
}
.padding(8)
}
}
これでSwiftUIだけで再現することができました。

スクロールが可能なフェードするテキスト表示
Discussion