1. 記事一覧 >
  2. ブログ記事
category logo

SwiftUI BOM付UTF-8のCSVファイルを出力して外部に保存

(更新) (公開)

はじめに

Swift(SwiftUI) で iPhone, iPad から BOM 付 UTF-8 のカンマ区切り CSV ファイルを保存できるアプリを作成しました。
アプリと言っても、ボタンが一つだけ有り、ダミーデータから CSV ファイルを出力するという最低限のものです。
今回、実装内容と速攻で実装して動作確認する手順を書きます。


【検証環境】

macOS Monterey 12.6

 Xcode 13.4.1

 Swift 5.7 (SwiftUI)

本記事の内容により何らかの不都合が生じても当方は一切責任を負いません。


動作内容

達成すべきことは、
・iPhone のテキストビューワー(ファイルアプリでタップして表示されるもの)で表示できる
・Windows のエクセル(バージョン 2209)で表示できる
・macOS のエクセルで表示できる
のみとします。


時刻名前数値
1年くらいランダムに遡った日時太郎[1〜10 の連番]0 ~ 9999 の適当な数


10 行生成します。
これをカンマ区切り CSV 化し、共有します。(シェアシートを表示します。)


動作内容は、以下です。


この例では、単純に本体に保存していますが、Teams のチャットルームにアップできたりします。


実装内容

詳細は、下に掲載のソースコード中のコメントを参照してください。


主に、以下の部分がキモになります。
"[文字列]" の [文字列] 部分に " が有る場合、"" とする。
エクセルに表示したときに文字化けするため、BOM 付 UTF-8 とする。

キモ ソースコード部分


【BOM 無し UTF-8 の場合】

BOM 無し UTF-8 の場合 文字化け

Windows、macOS のエクセルで文字化けします。iPhone のテキストビューワーや macOS のテキストエディットでは文字化けしません。

シュミレーターで保存したファイル(data.csv)は、/Users/<ユーザー名>/Library/Developer/CoreSimulator/Devices/<デバイスID>/data/Containers/Shared/AppGroup/<アプリID>/File Provider Storage から取り出せます。ターミナルで、find Library/Developer/CoreSimulator/Devices -name data.csv などで見つけられます。


ソースコード全体像です。
一応、実装手順 セクションで説明しますが、新規プロジェクトを作成後、ContentView.swift しか変更しません。

ContentView.swift
import SwiftUI

struct MyData {
    var timestamp: Date
    var name: String
    var number: Int
}

struct ContentView: View {
    var body: some View {
        Button(action: {
            var csv = ""
            do {
                for i in 1...10 {
                    // ダミーデータ作成
                    let randomNumber = Int.random(in:0...9999)
                    var goBackTime = 0
                    goBackTime = goBackTime + (Int.random(in:0..<365) * 24 * 60 * 60)
                    goBackTime = goBackTime + (Int.random(in:0..<24) * 60 * 60)
                    goBackTime = goBackTime + (Int.random(in:0..<60) * 60)
                    goBackTime = goBackTime + Int.random(in:0..<60)
                    let setTime = Calendar.current.date(byAdding: .second, value: ( goBackTime * -1 ) , to: Date())!
                    let myData = MyData(timestamp: setTime, name:"太郎" + String(i), number: randomNumber)
                    // myData は、
                    // timestamp: 1年くらいランダムに遡った日時
                    // name: 太郎[1〜10の連番]
                    // number: 0〜9999の適当な数
                    let formatter = DateFormatter()
                    formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"// 日時の表示フォーマットを設定
                    var line = ""
                    line += formatter.string(from: myData.timestamp) + "," // [日時フォーマット結果],
                    line += "\"" + ((myData.name).replacingOccurrences(of: "\"", with: "\"\"") as String) + "\","
                    // "[名前]", の[名前]に " が含まれていたら、" を "" に置換(今回は必ず含まれないが)
                    line += String(myData.number) + "\r\n" // [数字]¥r¥n
                    csv = line + csv //csv = CSVとして出力する内容全体
                }
            }
            csv = "時刻,名前,数値\r\n" + csv // 見出し行を先頭行に追加

            let tmpFile: URL = URL(fileURLWithPath: "data.csv", relativeTo: FileManager.default.temporaryDirectory)
            // テンポラリディレクトリ/data.csv の URL (ファイルパス)取得
            if let strm = OutputStream(url: tmpFile, append: false) {// 新規書き込みでストリーム作成
                // if let ... により、strm が nil ではないとき実行
                strm.open() // ストリームオープン(fopenみたいな)
                let BOM = "\u{feff}"
                // U+FEFF:バイトオーダーマーク(Byte Order Mark, BOM)
                // Unicode の U+FEFFは、表示がない文字。「ZERO WIDTH NO-BREAK SPACE」(幅の無い改行しない空白)
                strm.write(BOM, maxLength: 3)// UTF-8 の BOM 3バイト 0xEF 0xBB 0xBF 書き込み
                let data = csv.data(using: .utf8)
                // string.data(using: .utf8)メソッドで文字コード UTF-8 の
                // Data 構造体を得る
                _ = data?.withUnsafeBytes {//dataのバッファに直接アクセス
                    strm.write($0.baseAddress!, maxLength: Int(data?.count ?? 0))
                    // 【$0】
                    // 連続したメモリ領域を指す UnsafeRawBufferPointer パラメーター
                    // 【$0.baseAddress】
                    // バッファへの最初のバイトへのポインタ
                    // 【maxLength:】
                    // 書き込むバイトdataバッファのバイト数(全長)
                    // 【data?.count ?? 0】
                    // ?? は、Nil結合演算子(Nil-Coalescing Operator)。
                    // data?.count が nil の場合、0。
                    // 【_ = data】
                    // 戻り値を利用しないため、_で受け取る。
                }
                strm.close() // ストリームクローズ
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) {//念の為若干遅らせて、0.001秒後に実行(意味無いかも)
                // DispatchQueue.main.asyncAfter(deadline: .now() + 秒) { 遅延実行したい処理 }
                shareApp(shareText: csv, shareLink: tmpFile.absoluteURL  )
                // tmpFile.absoluteURL = テンポラリファイルの絶対パス(URL型)
            }
        }) {
            Image(systemName:"square.and.arrow.up")
                .resizable()
                .scaledToFit()
                .frame(width: 100, height: 100)
        }
    }
}

private func shareApp(shareText: String, shareLink: URL ) {
    let items = [shareLink] as [Any]// shareLink = テンポラリファイルの絶対パス(URL型)
    let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
    // UIActivityViewController:共有の時に画面外からフェードインしてくる画面。シェアシート。
    // activityItems:UIImage(画像)、URL(URL)、String(テキスト)など保存したいものを指定
    // applicationActivities:シェアシートをカスタマイズしたい時に使う。デフォルトで良い場合は、nil。
    if UIDevice.current.userInterfaceIdiom == .pad {// デバイスがiPadだったら
        let deviceSize = UIScreen.main.bounds// 画面サイズ取得
        if let popPC = activityVC.popoverPresentationController {
            // ポップオーバーの設定
            // iPadの場合、sourceView、sourceRectを指定しないとクラッシュする。
            popPC.sourceView = activityVC.view // sourceRectの基準になるView
            popPC.barButtonItem = .none// ボタンの位置起点ではない
            popPC.sourceRect = CGRect(x:deviceSize.size.width/2, y: deviceSize.size.height, width: 0, height: 0)// Popover表示起点
        }
    }
    let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
    // UIWindowScene のインスタンス作成
    // 【UIApplication】
    // アプリケーションを制御、管理するクラス
    // 【.shared】
    // シングルトンのUIApplicationインスタンスにアクセス
    // 【.connectedScenes】
    //  アクティブになっているシーンにアクセス(Set<UIScene>)
    // 【.first as? UIWindowScene】
    // マルチウィンドウでは無いため、単純に一番最初の要素にアクセスして、UIWindowScene にキャスト
    let rootVC = windowScene?.windows.first?.rootViewController
    // rootVC = rootViewController:アプリ初期画面(大元のViewController)
    rootVC?.present(activityVC, animated: true,completion: {})
    // アニメーション有りでシェアシート(activityVC)表示(present)
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

実装手順

Xcode を起動して、Create a new Xcode project をクリックします。 Create a new Xcode project クリック


Xcode を起動して、App を選択して、Next をクリックします。 App 選択 Next クリック


Project Name: [任意]
Team: [任意]
Organization Identifier: [任意]
Bundle Identifier: [任意]
Interface: SwiftUI
Language: Swift
Use Core Data: チェック無し
Include Tests: チェック無し
とし、Next をクリックします。

項目入力 Next クリック


保存する場所を選択して、Create をクリックします。 保存する場所を選択して、Create クリック


ContentView.swift の内容をそっくりそのまま上記の内容に差し替えます。 ContentView.swift 差し替え


シュミレーターで起動してみます。(動画は、iPhone12のシュミレーターです。)


ヨシ!

loading...