🦋

SwiftUI: iOSの電卓みたいなテキスト表示を実装する

に公開

iOSに標準でついている電卓をよく観察すると、画面の幅よりも数式が長くなってくると両端にフェードが入ることがわかります。

さらによく観察すると以下のような仕様であることがわかります。

  • 数式が画面の幅より短い時
    • スクロールしない
    • フェードしない
  • 数式が画面の幅より長い時
    • スクロールする
    • 数式の左右が表示領域外にはみ出す時に限りフェードする
  • 数式の文字列に変更があった場合は末尾が表示されるように自動スクロールされる

この仕様を満たすテキスト表示をSwiftUIで実装してみましょう。

実装の概要

  • ScrollViewTextを入れつつ、そのTextbackgroundGeometryReaderを入れることでTextの位置やサイズの情報を取得する
  • 位置の座標は親のScrollViewに対するTextの相対座標なため、coordinateSpace(name:).frame(in: .named(_:))を用いる
  • GeometryReaderを用いて取得できる情報をScrollViewの外側で使うためにPreferenceKeyonPreferenceChange(_:perform:)を用いる
  • フェードが必要かどうかは表示領域の幅とTextの相対座標から判別する
  • フェードは.mask()LinearGradientを用いて実現する
  • ViewScrollViewに入れつつも、スクロールする必要がないほど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