【SwiftUI】Create Draggable Reorder ListView without List

👉 Drag and Drop List In SwiftUI. In this article, We will explore how to… | by Mobile Apps Academy | Medium

よくある UI の挙動を SwiftUI でどれだけシンプルに作れるのか。

やってみました。

本来は、何かを NSItemProvider() 経由で、

ドロップ先に渡すのが役目っぽいけども、

DropDelegate の便利さを利用して

配列を並び替えるイメージ。

並び替えのアニメーションは withAnimation デフォルトに頼る。

iOS と macOS、Preview と シュミレータ と 実機、OS バージョンなど、

互換しようとするといろいろありそう。

ここらのコンポーネントはまだ不安定な感じ ?



【Swift】FileManager を使いたい

最初、無駄にややこしくて使いづらい気がした。

しかし、整理できるとそうでもない。


let fileManager = FileManager.default
let documents = URL.documentsDirectory

Button("create file") {
  // new.txt
  let text = "Hello!"
  let to = documents.appending(component: "new.txt")
  try? text.write(to: to, atomically: true, encoding: .utf8)
}

Button("read file") {
  // new.txt
  let file = documents.appending(component: "new.txt")
  let text = (try? String(contentsOf: file, encoding: .utf8)) ?? "ERROR"
  print(text)
}

Button("create directory") {
  // some/
  let directory = documents.appending(component: "some/")
  try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}

Button("copy file") {
  // new.txt - copy -> some/copied.txt
  let at = documents.appending(component: "new.txt")
  let to = documents.appending(component: "some/copied.txt")
  try? fileManager.copyItem(at: at, to: to)
}

Button("move file") {
  // new.txt - move -> some/moved.txt
  let at = documents.appending(component: "new.txt")
  let to = documents.appending(component: "some/moved.txt")
  try? fileManager.moveItem(at: at, to: to)
}

Button("copy directory") {
  // some/ - copy -> another/
  let at = documents.appending(component: "some/")
  let to = documents.appending(component: "another/")
  try? fileManager.copyItem(at: at, to: to)
}

Button("delete file") {
  // some/moved.txt
  let dir = documents.appending(component: "some/")
  let file = dir.appending(component: "moved.txt")
  try? fileManager.removeItem(at: file)
}

Button("delete directory") {
  // some/ and another/
  let dir1 = documents.appending(component: "some/")
  let dir2 = documents.appending(component: "another/")
  try? fileManager.removeItem(at: dir1)
  try? fileManager.removeItem(at: dir2)
}

つづいて、ディレクトリを指定するだけで、

そのディレクトリ内に存在するディレクトリとファイルの状態を表示できるようにしておきます。

実体の確認を頻繁にしやすくしておくこと大事。



extension FileManager {
  private func contents(directory url: URL) -> [URL] {
    (try? contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])) ?? []
  }

  func showContents(_ url: URL = .documentsDirectory) {
    print(
      String(format: "%@ %@", url.shortPath(), url.isFile ? "[\(url.fileSize)]" : "")
    )

    if url.isDirectory {
      contents(directory: url)
        .sorted(by: {
          let lr = [$0, $1].map {
            ($0.path().components(separatedBy: "/").dropLast().joined(), $0.path())
          }
          return lr[0] < lr[1]
        })
        .forEach { content in
          showContents(content)
        }
    }
  }
}


FileManager.default.showContents(.documentsDirectory)

// /HOME/Documents/ 
// /HOME/Documents/new.txt [6 bytes]
// /HOME/Documents/another/ 
// /HOME/Documents/another/copied.txt [6 bytes]
// /HOME/Documents/some/ 
// /HOME/Documents/some/copied.txt [6 bytes]
// /HOME/Documents/some/moved.txt [6 bytes]

👉 contentsOfDirectory(at:includingPropertiesForKeys:options:) | Apple Developer Documentation hatena-bookmark

これぐらいでいいか。

Permission とか mask の話しはまたそのうちやりたいです。



 

🤔 まとめ

コツとしては、URL の取り扱い。


ファイルやディレクトリは URL を使って指す。


URL を path() などを使って String に変換するのは表示直前のみ。


URL は実体の情報を保持していない。

という当たり前の言葉が思いつく。

しかし、最初は混乱したし、すると思う。

String のほうが直感的で人間に近いし、

古い API を見てると String ベースでファイルを操作してる。

フツーに検索しても古い記述がヒットすることが多い。

 

🤔 参考

👉 【Swift】ファイルやディレクトリ操作するための extension をまずは作った hatena-bookmark
👉 【Swift】URL で特定のディレクトリやファイルを指す hatena-bookmark


【Swift】よく使われている zip ファイルユーティリティ 2つ

よく似た Star数の2つ。

どっちが使いやすそうでしょうか。

 

🙆🏻‍♂️ ZIPFoundation


import ZIPFoundation


try fileManager.zipItem(at: sourceURL, to: destinationURL)


try fileManager.unzipItem(at: sourceURL, to: destinationURL)

共に、利用する FileManager の extension になっています。

👉 weichsel/ZIPFoundation: Effortless ZIP Handling in Swift hatena-bookmark

 

🙆🏻‍♂️ Zip


import Zip


let zipFilePath = try Zip.quickZipFiles([filePath], fileName: "archive")


let unzipDirectory = try Zip.quickUnzipFile(filePath)

Zip クラスの static な関数を利用するようです。

👉 marmelroy/Zip: Swift framework for zipping and unzipping files. hatena-bookmark

 

🙆🏻‍♂️ まとめ

共に、ファイル位置は「URL」を引数として使います。

もしかして、ビルトインで何か関数あるの?


【SwiftUI】市松模様を背景にする - Checkered Pattern Background 🏁

既存 View の 背景色。

分からないときありません ?

白なのか、グレーなのか、

透過しているのか、マテリアル的なやつなのか。

 

🏁 市松模様 Checkered Pattern

作っておきます。


struct CheckeredPattern: View {
  var size: CGFloat

  var body: some View {
    GeometryReader { gr in
      Grid(horizontalSpacing: 0, verticalSpacing: 0) {
        ForEach(0 ..< Int(ceil(gr.size.height / size)), id: \.self) { y in
          GridRow {
            ForEach(0 ..< Int(ceil(gr.size.width / size)), id: \.self) { x in
              (x % 2 == y % 2 ? Color.gray : Color.white)
                .frame(width: size, height: size)
                .opacity(0.25)
            }
          }
        }
      }
    }
  }
}

🏁 市松模様 - Checkered Pattern

 

🏁 Preview で使う


struct BackgroundCheckeredPattern<Content: View>: View {
  var size: CGFloat
  @ViewBuilder var content: () -> Content

  var body: some View {
    ZStack {
      CheckeredPattern(size: size)
        .edgesIgnoringSafeArea(.all)
      content()
    }
  }
}

 

🏁 extension 化

Preview などで使いやすように extension にしておきます。


extension View {
  func backgroundCheckeredPattern(size: CGFloat) -> some View {
    ZStack {
      CheckeredPattern(size: size)
        .edgesIgnoringSafeArea(.all)
      self
    }
  }
}

特に modifier まで作ることはないですね。


Button("Button") {
}
.buttonStyle(.bordered)
.controlSize(.extraLarge)
.backgroundCheckeredPattern(size: 15) // *

🏁 市松模様 - Checkered Patter

 

🏁 まとめ

サンプルコードとして Gist 化しておきます。



List の背景って .scrollContentBackground(.hidden) で消すんですね。

List の背景って .scrollContentBackground(.hidden) で消す

便利に使えるコードはたくさん持っておきたいです。


【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 として、

text と debouncedText

の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 を使ったほうがスッキリします。