【SwiftUI】枠線付き角丸ボタンを簡単に作りたい

しみじみ勉強してきたのに、


なぜか簡単に作れないこういうボタン。

ので、少しやってみました。



 

🧑🏻‍💻 まず思いついた記述

便利な BorderdProminentButtonStyle().overlay() で 角丸枠線をのせます。


// borderProminent + overlay

Button {
} label: {
  VStack {
    Label("BorderedProminent", systemImage: "face.smiling")
    Text(".overlay()")
      .font(.caption)
  }
  .padding()
}
.buttonStyle(.borderedProminent)
.tint(.orange)
#if os(macOS)
.clipShape(.rect(cornerRadius: 16))
#endif
.overlay(RoundedRectangle(cornerRadius: 16).stroke(.gray))

macOS では角がずれる。

ので、clipShape() しました。

気持ち悪いですね。

 

🧑🏻‍💻 よくある記述

この書き方が多いらしいです。

ラベル の View に modifier のチェイン。


// plain + label view modifiers
      
Button {
} label: {
  VStack {
    Label("Plain", systemImage: "face.smiling")
    Text(".background(.orange, in: .rect(cornerRadius: 16))")
      .font(.caption)
    Text(".overlay()")
      .font(.caption)
  }
  .foregroundStyle(.white)
  .padding()
  .background(.orange, in: .rect(cornerRadius: 16))
  .overlay(RoundedRectangle(cornerRadius: 16).stroke(.gray))
}
.buttonStyle(.plain)

ButtonStyle は .plain 一択でしたが、

見た目も、ボタンを押したときの挙動も、

問題ないように見えます。

ただ、コードが長ったらしくなるので、

同様なボタンの数が増えると見通しが悪くなりそうです。

 

🧑🏻‍💻 カスタム ButtonStyle

ビルトインにあっても良さそうなのに。

押したときエフェクトは、BorderdProminentButtonStyle() になるべく似せて ButtonStyle を作ります。


struct StrokeRoundedRectangleButtonStyle: ButtonStyle {
  var cornerRadius: CGFloat

  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .foregroundStyle(
        .white.opacity(configuration.isPressed ? 0.75 : 1)
      )
      .padding()
      .background(
        .orange.opacity(configuration.isPressed ? 0.75 : 1),
        in: .rect(cornerRadius: cornerRadius)
      )
      .overlay(
        RoundedRectangle(cornerRadius: cornerRadius)
          .stroke(.gray.opacity(configuration.isPressed ? 0.75 : 1))
      )
  }
}

extension ButtonStyle where Self == StrokeRoundedRectangleButtonStyle {
  static var strokeRounded: Self { Self(cornerRadius: 16) }
}

ButtonStyle を作ってしまえば、あとは簡単にボタンに適用できます。


// custom style

Button {
} label: {
  VStack {
    Label("Custom Button Style", systemImage: "face.smiling")
    Text("extension ")
      .font(.caption)
  }
}
.buttonStyle(.strokeRounded)

しかし、コード量は多い。

 

🧑🏻‍💻 まとめ

レイアウトの重なりの考え方として、この場合は、

角を丸くするのは .background() で、

枠線はそれに合わせて .overlay() する、

のが良さげに思えました。

あと、

iOS と macOS のコード共用は、シビアなレイアウトになると厳しいのですね!

👉 Customizing the Appearance and Interaction Behavior of Buttons | Fatbobman's Blog hatena-bookmark


【SwiftUI】TextField で debounce | Debouncing TextField

みんなは、どんな実装にしていますか。

入力文字を監視しつつの検索結果のリアルタイム反映的なやつを作るとき。

詰まる感じをどう解消していますか。

Debounce
only emit an item from an Observable if a particular timespan has passed without it emitting another item

👉 ReactiveX - Debounce operator hatena-bookmark

👉 debounce(for:scheduler:options:) | Apple Developer Documentation hatena-bookmark


import Combine

public final class DebounceObject: ObservableObject {
  @Published var text: String = ""
  @Published var debouncedText: String = ""
  private var bag = Set<AnyCancellable>()

  public init(dueTime: TimeInterval = 0.5) {
    $text
      .removeDuplicates()
      .debounce(for: .seconds(dueTime), scheduler: DispatchQueue.main)
      .sink(receiveValue: { [weak self] value in
        self?.debouncedText = value
      })
      .store(in: &bag)
  }
}


struct SearchView: View {
  @StateObject var debounceObject = DebounceObject()

  var body: some View {
    VStack {
      TextField(text: $debounceObject.text)
        .onChange(of: debounceObject.debouncedText) { text in
          // perform search
        }
    }
  }
}

👉 How to debounce TextField search in SwiftUI | Swift Discovery hatena-bookmark

combine てのもう時代遅れなのですか ?

今現在、本流の本筋の考え方のわかりやすいシンプルな記述を探します。

 

🤔 Tunous/DebouncedOnChange

直感的に使いやすい ViewModifier ライブラリです。

👉 Tunous/DebouncedOnChange: SwiftUI onChange View extension with debounce time hatena-bookmark

コード量が少ないので内部も把握しやすいです。

基本的な使い方は、0.25 秒をデバウンス時間として以下のように書けます。


TextField("Search-1", text: $text)
  .onChange(of: text, debounceTime: .seconds(0.25)) { newValue in
    debouncedText = newValue
  }

使ってみると分かるのは、View 内に @State として、

textdebouncedText

の2つが存在してしまうことが面倒です。

なので、それを避けるために、

DebounceTextField として View を外出しにして使います。


DebounceTextField2(titleKey: "Search-2", debouncedText: $debouncedText)


struct DebounceTextField2: View {
  var titleKey: String
  @Binding var debouncedText: String

  @State private var local = ""

  var body: some View {
    TextField(titleKey, text: $local)
      .onChange(of: local, debounceTime: .seconds(0.25)) { newValue in
        debouncedText = newValue
      }
  }
}

onChange() を使うのはどうなのか。

 

🤔 Binding をカスタムする

そもそも @State は、

@Binding な引数をもつコンポーネントに対して双方向に値が流れる

ということをきちんと頭に置きながら考えてみます。



struct DebounceTextField3: View {
  var titleKey: String
  @Binding var debouncedText: String

  @State private var delay: Task<Void, Never>?

  var body: some View {
    TextField(
      titleKey,
      text: Binding(
        get: { debouncedText },
        set: { newvalue in
          delay?.cancel()
          delay = Task {
            do {
              try await Task.sleep(for: .seconds(0.25))
            } catch { return }
            debouncedText = newvalue
          }
        }
      )
    )
  }
}

続いて、ちょっとしっかりしたライブラリを使ってみます。

👉 boraseoksoon/Throttler: One Line to throttle, debounce and delay: Say Goodbye to Reactive Programming such as RxSwift and Combine. hatena-bookmark


struct DebounceTextField4: View {
  var titleKey: String
  @Binding var debouncedText: String

  var body: some View {
    TextField(
      titleKey,
      text: Binding(
        get: { debouncedText },
        set: { newvalue in
          debounce(.seconds(0.25)) {
            debouncedText = newvalue
          }
        }
      )
    )
  }
}

 

🤔 まとめ

Custom Binding を使ったほうがスッキリします。



【SwiftUI】ScrollView や List を snap する


作ってみると、何かがきもい選択肢リストの挙動。

上下の padding 付近が気になります。

 

🧑🏻‍💻 snap する

スクロールしたときの

選択肢のアイテムを

きりのいいところで止まるようにします。

ScrollView を使ったリストの実装に、

.scrollTargetLayout()

.scrollTargetBehavior(.viewAligned)

の2行を追加するだけできっちり止まるようになります。

👉 ScrollTargetBehavior | Apple Developer Documentation hatena-bookmark



計算とかしなくていいです。

便利。

ページングもできるとかすげえ。

👉 [WWDC2023] iOS17におけるScrollViewの新機能  その1 hatena-bookmark
👉 UICollectionView with Snapping and Scaling in Swift | by Satsuki Hashiba | Medium hatena-bookmark

 

🧑🏻‍💻 結果

ScrollView 上下のきわきわがすっきりしました !

しかし、「snap」て難しい英単語ですね。

👉 snapの意味・使い方・読み方|英辞郎 on the WEB hatena-bookmark

「スナップエンドウ」てやつ、

前から気にはなってました。


【SwiftUI】shadow() が内側まで効いて困る - compositingGroup()

こんな表示。


VStack {
  Text("こちらへ")
  Text("▶")
}
.padding()
.background(.yellow)

看板ぽく影を付けます。

.shadow() ですね !

簡単ですね !


VStack {
  Text("こちらへ")
  Text("▶")
}
.padding()
.background(.yellow)
.shadow(color: .black, radius: 2, x: 5, y: 5) // *

...

なんでや !?

VStack だけ効けばいいのに、

内側の文字まで効いてますが。

 

👩🏼‍💻 対応方法

.shadow() の前で、

.compositingGroup()

が良さげです。

Wraps this view in a compositing group.

👉 compositingGroup() | Apple Developer Documentation hatena-bookmark


VStack {
  Text("こちらへ")
  Text("▶")
}
.padding()
.background(.yellow)
.compositingGroup() // *
.shadow(color: .black, radius: 2, x: 5, y: 5)

compositingGroup() の代わりに、

.clipShape(.rect)

.clipped()

でも、内側 Text() への .shadow() の影響を

切り離すことができるようです。

しかし、

なんで .shadow() だけなんやろな。

他は、この方法で影響を切り離すことはできない模様。



そもそもは、文字がなぜかぼやけてる? てのがきっかけでした。


【Xcode】Indexing | Processing files could not complete

これ。

Preview やDerivered data を削除しても、

Build folder をクリーンしても、

Xcode を再起動しても

Index Procccessing Files のまま固まって

Preview のビルドができなくなった場合の対応手順。

1. Open your Project Folder.
2. Find ProjectName.xcodeproj file.
3. Right-Click Copy and Paste to Safe Place.
4. Right-Click Show Package Contents.
5. Find project.xcworkspace file and delete that file.
5. Reopen Your Project and clean and Rebuild.

👉 Xcode stuck on Indexing - Stack Overflow hatena-bookmark

覚えておくのは、

「プロジェクト名」.xcodeproj ファイルを

右クリックから開いて、

project.xcworkspace を捨てる。

とりあえず、これで復旧できます。

スタックの原因自体は、

特定のコードのバグのようなので、

おおまかに位置を特定したら、

最初にコメントアウトしておかないと、

また固まっちゃうので注意です。