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

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 / SwiftData】Using ViewModifier for setting ModelContainer 🔌

ViewModifiers can be used in nested parent-child Views for each respective #Preview, making it convenient. This also enhances clarity even further.

 

🔌 Using as an extension of View

Create a custom ViewModifier.


struct DogDataContainerViewModifier: ViewModifier {
  func body(content: Content) -> some View {
    content
      .modelContainer(try! ModelContainer(for: Dog.self))
  }
}

All ViewModifiers will be turned into extensions.


extension View {
  func dogDataContainer() -> some View {
    modifier(DogDataContainerViewModifier())
  }
}

It is used in the implementation of the parent as well as in the #Preview of the child.


struct DogView: View {
  // ...
}

#Preview {
  DogView()
    .dogDataContainer()
}

 

🔌 Initializing or creating data

Additionally, as there are often data initialization or creation tasks, I'll add those.

This will be in the part with View.onAppear().

We'll use ModelContext to manipulate the data.


struct GenerateDataViewModifier: ViewModifier {
  @Environment(\.modelContext) private var modelContext
    
  func body(content: Content) -> some View {
    content.onAppear {
      DataGeneration.generateAllData(modelContext: modelContext)
    }
  }
}

This will also be made into an extension.


extension View {
  func generateData() -> some View {
    modifier(GenerateDataViewModifier())
  }
}

Let's add this to the initial code.


struct DogDataContainerViewModifier: ViewModifier {
  func body(content: Content) -> some View {
    content
      .generateData()
      .modelContainer(try! ModelContainer(for: Dog.self))
  }
}

 

🔌 Conclusion

I'll summarize it.


struct DogDataContainerViewModifier: ViewModifier {
  func body(content: Content) -> some View {
    content
      .generateData()
      .modelContainer(try! ModelContainer(for: Dog.self))
  }
}

struct GenerateDataViewModifier: ViewModifier {
  @Environment(\.modelContext) private var modelContext
    
  func body(content: Content) -> some View {
    content.onAppear {
      DataGeneration.generateAllData(modelContext: modelContext)
    }
  }
}

extension View {
  func dogDataContainer() -> some View {
    modifier(DogDataContainerViewModifier())
  }
}

fileprivate extension View {
  func generateData() -> some View {
    modifier(GenerateDataViewModifier())
  }
}

When using, only basic public extensions are used.


struct DogView: View {
  // ...
}

#Preview {
  DogView()
    .dogDataContainer()
}

It can also be used on the implementation side.

For reference, below is Apple's official sample code.

👉 sample-backyard-birds/BackyardBirdsData/General/BackyardBirdsDataContainer.swift at 1843d5655bf884b501e2889ad9862ec58978fdbe · apple/sample-backyard-birds hatena-bookmark