見出し画像

SwiftUIでドラッグ&ドロップ

Drag and Drop(以下ドラッグ&ドロップ)はiOSでは比較的新しい機能です。
UIKitではいろいろ細かくコードでの対応が必要になります。
でもSwiftUIはかなりシンプルにドラッグ&ドロップに対応できます。
【2020年12月9日追記 ❶ iPadでは別アプリとドラッグ&ドロップ可能、❷ macOS Big SurならPlaygroundsでドラッグ&ドロップが実行可能】

※この記事ではSwift言語の基本的な知識を前提にしています。
コード内のキーワードや書式などの不明点は Swift5初級ガイド などを参照してください。

※SwiftUIについて全体的なことは『SwiftUI最初の一歩』、コードの基本とビューについては『SwiftUIの文法 その1 View』を参照してください。

お知らせ
電子書籍『Swift5初級ガイド』をAppleのブックストアから出しています。
文字サイズを好みに変更でき、本文を検索可能な電子書籍です。
無料サンプルでご確認ください。
MacでもiPadでもiPhoneでも読めます。
Xcode 12に対応した第7版がダウンロード可能です。(ご購入済みの場合は無料アップデートです)
(2020年10月12日に第7版にアップデートしました)
Swift 5.3は第6版で対応済みです。
ブックストアから一度購入すると今後のアップデートは無料で読めます。

7宣伝store

・・・

・画像クリックで拡大表示できます
・画像を拡大表示中は画像の左右をクリックで画像だけを順に表示できます
・ソースコード部分は横にスクロール表示できます

サンプルはXcode 11.7のPlaygroundで作成しiPadの Playgrounds 3.4とXcode 12.2のPlaygroundで動作を確認しました。
この記事の最後(有料部分)にあるリンクから完全なサンプルをダウンロードできます。

今回のサンプルコードはMacのSwift Playgroundsアプリでは実行できません
【macOS Big Surなら若干問題ありですが実行可能です】

Xcode 11.7で実行する場合初回のみLive Viewの操作ができません。
2度目以降(マウスポインタが指であれば)は問題ありません。


1 ドラッグ&ドロップの対応は骨が折れる

UIKitでドラッグ&ドロップに対応するのは結構大変です。
Drag and Dropのページの「First Steps」には三つのArticleと二つのサンプルコードが載っています。
関係する型もたくさんあり、最初に勉強しなければならないことが多数あります

一方SwiftUIでは必要なコードもかなり少なく、比較的楽にドラッグ&ドロップに対応することができます。
ただしSwiftUIでのドラッグ&ドロップはまだ発展途上で若干の問題があります。

今回は「ドラッグ&ドロップ」のしくみとSwiftUIでのコーディングと問題点と対策を解説します。(ドロップした時の動作は『コピー』のみ扱います)

ドラッグ&ドロップの機能はSwiftUIでもUIKitと同じOSのしくみを使っています。
定型のコード入力の多くははぶくことができますが、内部での動きは同じです。
このため必要情報はUIKitのドキュメントを参照してください。
UIKitのドラッグ&ドロップ関連Article
Understanding a Drag Item as a Promise(英文)
Making a View into a Drag Source(英文)
Making a View into a Drop Destination(英文)

1-1 ドラッグ&ドロップのガイドライン(HIG)

ヒューマン・インターフェース・ガイドラインのDrag and Drop(英文)には、ドラッグ&ドロップ挙動の規範(目標)がかかれています。
(iOS用のページ)

「選択および編集可能なすべてのコンテンツでドラッグ&ドロップを使用できるようにします。」と書かれています。

本文の中で関連する技術情報(UIKit)へもリンクしています。

ドラッグ&ドロップは大雑把に言えば『コピー・ペーストカット・ペースト』の発展したものです。
『対象を選びコピーまたはカットして、ペースト先を選びペーストする』の一連の操作を、連続したマウスなどポインティングデバイス操作(ジェスチャー)で実現します。

コピー/カット/ペーストではメニュー項目など操作者に対するヒントがありますが、『ドラッグ&ドロップ』ではそれがあまりありません。
そのためドラッグ開始でドラッグするデータのサムネイルを表示したり、ドロップでコピー可能な範囲で+マーク(バッジ)を表示するなど細かなガイドラインが用意されました。

1-2 ドラッグしているのは「何」か

ドラッグ&ドロップでドラッグしているのは実際のデータです。
基本的にはテキスト、画像、URL、カスタムデータなどです。
動画や音声などのデータまたはファイルもドラッグ可能にできます。

実際のデータは(ドラッグ開始時には用意せず)ドロップ時に受け渡す手段も選べます。
(この記事ではそこまでは解説していません)

単純なデータだけではなく、それが何かを表す 情報 も持っています。
複数のデータを同時にドラッグし受け取り側が選べるようになっています。
画像であれば ❶拡大しても品質を保つPDFのベクトル表現、❷透明度を持つロスレスのPDF画像、❸透明度のない圧縮されたJPEGなどです。
このようにすると受け取る側で最適のものを利用できます。
逆にJPEGデータだけでは透明度などの情報を渡すことができません。

1-3 ソースと宛先

「ドラッグ&ドロップ」とひとまとめで語られますが、ドラッグの開始(ソース)とドロップ処理(宛先)は独立した別のコードで対応します。

アプリの仕様によって、ソースだけ、宛先だけ、ソースも宛先も一つの画面に持つ、ソースと宛先を兼ねるビュー...などの場合があります。

1-4 必ずOSの機能を使う

ドラッグ開始時にはアプリ内へのドロップか別アプリへのドロップか決められません。(ドロップをキャンセルする場合もあります)

iOSもmacOSも別のアプリとは直接やりとりはできません
アプリ単位でメモリが保護されているためです。
このため「ドラッグ&ドロップ」はOSに共通の機能を用意し各アプリは、OSの『ドラッグ&ドロップ機能』とやりとりすることで実現されています。

OSとのやり取りのために複雑になる部分ももちろんあります。

関連する型に「NS」で始まるものも登場します。
「ドラッグ&ドロップ」はmacOS由来(当時はOS X)のためです。


2 SwiftUIのドラッグ&ドロップ

SwiftUIでは「ドラッグ&ドロップ」がかなり楽に実現できます。
でもOSの機能とやり取りすることに変わりはありません。

最初のサンプルはとてもシンプルです。
Textビューに表示している文字列をドラッグ可能にしました。
ドロップを受けるのは標準のTextFieldビューです。

// サンプル1
import SwiftUI
import PlaygroundSupport

struct Sample11View: View {
  @State private var getText: String = ""
  let str = "「SwiftUIとドラッグ&ドロップ」サンプル"

  var body: some View {
     VStack {
        Text(str)
           .onDrag {
              NSItemProvider(object: self.str as NSString)
           }
           .padding()
        TextField("ここにドロップして", text: $getText)
           .padding()
           .border(Color.black)
           .padding()
     }
     
  }
}

// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(Sample11View())

二つのimport文と最後のPlaygroundPage.current.setLiveView(...の行はどのサンプルも共通で必要です。

(2追加情報)iPadでは別アプリとドラッグ&ドロップ可能【2020年12月9日追記❶】

iPadOS 14.2 ではこのコードをPlaygroundsアプリで実行中に、別のアプリとのドラッグ&ドロップも可能です。

別アプリ

Safariを(Split ViewまたはSlide Overで)開き、PlaygroundsからドラッグしSafari画面へドロップできます。
逆に別アプリからPlaygroundsへドラッグ&ドロップも可能です。
ドロップを受けるコードでは『"結果"を有効にする』をオフにして実行してください。

Playgrounds画面はライブビュー部分を広げコードを見せない状態にもできます。
ライブビューだけだと独立したアプリのように見えこのように実際に使えます。SwiftUIとPlaygroundsのポテンシャルを実感してください。

2-1 関連する型

SwiftUIの「ドラッグ&ドロップ」のドキュメントは View and Controls > View > Input and Events ページの「Drag and Drop」に載っています。

class NSItemProvider 
ドラッグ&ドロップやコピー&ペーストでやり取りするデータを扱うための型です。

struct UTType 
データの種類を表すための型です。
独自の型を追加登録することもできます。
iOS14以降が必要なため今回のサンプルでは使っていません。

struct DropInfo 
ドロップの状態を表すための型です。

protocol DropDelegate 
ドロップ操作を受け入れるインターフェースのためのプロトコルです。

DropProposal 
ドロップ動作がcopyかmoveかなどを表します。

などがあります。

2-2 発展途上

MacのSwift Playgroundsアプリではドラッグを開始しない問題があります。(macOS 10.15.7で実行した場合)
今回のサンプルはXcodeのPlaygroundか、iPadのSwift Playgroundsアプリのみで動作します。(Xcode 11.7と12.2で確認)

コード実行中の動きもXcodeのバージョンやSwift Playgroundsアプリで細かな違いがありました。
同じコードをアプリにビルドした場合とPlaygroundで動かした場合も微妙な違いもありました。

(2-2追加情報)macOS Big Surなら実行可能【2020年12月9日追記❷】

MacのSwift Playgroundsアプリではドラッグを開始しない問題についてmacOS 11.0.1 Big Surで確認したところ一部問題はあるものの動作しました

ドラッグ可能にしたビューを長押ししてもイメージが浮き上がるアニメーションは実行されません
この状態で(タッチしたまま)、タッチ位置を動かすと半透明イメージが下からあらわれドラッグが可能が可能です。

長押ししてもタッチ位置をうごかさない限りドラッグ可能なことがわからない状態です。
macOS 11.0.1でPlaygrounds3.4のドラッグ&ドロップのコードを実行すると、タッチして移動(長押しはほとんど不要)でドラッグできます。

後半のサンプルコードで確認したところドロップを受けることもできました。
なおドロップできないことを明示するバッジは macOS 11.0.1 でPlaygrounds 3.4 を実行した場合には表示されません。
UI的にはドラッグ可能なことがわかりにくい状態ですが、今後改善されることが期待できます。

macOS 11.0.1 でなら Swift Playgrounds アプリ(バージョン3.4)でドラッグ&ドロップのコードを実行することができます。
ソースコード部分からのドラッグを受けたり、ライブビューからソースコード部分へのドロップも可能です。
Safariなど別アプリとライブビューで相互にドラッグ&ドロップも可能です。


3 ドラッグ可能にする

SwiftUIでも通常のViewインスタンス(ビュー)はドラッグ可能ではありません。

サンプル1のようにonDrag(_:)モディファイアーを使ってドラッグ可能にします。

3-1 ドラッグ関係の型

NSItemProvider型インスタンスで「何を」ドラッグするかをOS側に伝えます。
Foundationフレームワークに含まれます。

サンプル1ではinit(object:)を使ってインスタンスを生成しています。

ドラッグするデータはProtocol NSItemProviderWritingに準拠していなければなりません。
すでに次の型がこのプロトコルに準拠しています。
AVFragmentedAsset、AVURLAsset、CNContact、CNMutableContact、CSLocalizedString、MKMapItem、NSAttributedString、NSMutableString、NSString、NSTextStorage、NSURL、NSUserActivity、UIColor、UIImage

これらの型のインスタンスはドラッグ&ドロップが可能です。

そのほかの型をドラッグ&ドロップ対応にすることも可能ですが、この記事では触れません。

SwiftのString型はそのままでは使えませんが、NSStringは使うことができます。
画像はUIImageが使えます。

サンプル1では NSItemProvider(object: self.str as NSString) の部分でNSItemProviderインスタンスを文字列データから生成しています。

str as NSStringでSwiftのString型インスタンスをNSString型インスタンスとして引数に渡します。

SwiftのString型は最初からNSString型にブリッジ可能に設計されています。

3-2 ドラッグを可能にするコード

ViewプロトコルのonDrag(_:)モディファイアーを使うとビューインスタンスをドラッグ可能になります。

// ビューをドラッグ可能にするonDrag(_:)モディファイアー
func onDrag(_ data: @escaping () -> NSItemProvider) -> some View

引数はひとつでNSItemProvider型インスタンスを返すクロージャーです。
ドラッグするデータを指定します。

@escapingがついているのは、このクロージャーが実行されるのは長押しのジェスチャーをドラッグ開始と判断し、OSが別のスレッドから実行するためです。

onDrag(_:)はドラッグ&ドロップのための適切なジェスチャー対応をビューに追加します。

長押しのジェスチャーをドラッグ開始と判断すると、ドラッグする半透明の画像も自動で作られます。(ドラッグ開始時にビューの表示状態を、ドラッグ中のプレビュー画像として作成し利用します。)

最初のサンプルでは長押しするとイメージが浮き上がり二重に見えます❶。
この時ドラッグの準備はできています。
タッチ位置をずらすとイメージが小さくなりタッチ位置に従って移動します❷。(画面は合成です)

画像3

ドラッグしたままドロップ可能なビューの上にタッチ位置が入ると緑の+を表示してドロップ可能(ドロップした場合は移動ではなくコピー)を示します❸。
その状態でタッチをはなすとドロップします❹。

画像3

このようにSwiftUIのビューをドラッグ可能にするのはとてもシンプルです。

ただしMac版のSwift Playgrounds 3.4アプリではドラッグを開始できない問題がありました。
同じコードでXcodeでは動作するのでPlaygroundsアプリの今後のアップデートで修正されることを期待します。

3-3 画像をドラッグ可能にする

画像をドラッグ可能にするにはドラッグする画像データをUIImageのインスタンスで準備します。UIImageクラスはNSItemProviderWritingプロトコルに準拠しているのでそのまま利用可能です。

サンプル1にSF Symbolsのイメージを表示を追加しドラッグ可能にしましょう。
UIImageのイニシャライザー init?(systemName name: String) を使います。

// サンプル2
struct Sample11View: View {
  @State private var getText: String = ""
  let str = "「SwiftUIとドラッグ&ドロップ」サンプル"
  let weatherImage = UIImage(systemName: "cloud.sun.rain")!

  var body: some View {
     VStack {
        Text(str)
           .onDrag {
              NSItemProvider(object: self.str as NSString)
           }
           .padding()
        TextField("ここにドロップして", text: $getText)
           .padding()
           .border(Color.black)
           .padding()
        Image(uiImage: weatherImage)
           .resizable()
           .frame(width:50, height:50)
           .background(Color.white)
           .onDrag{
              NSItemProvider(object: self.weatherImage)
           }
     }

  }
}

import 文と最後の文は共通なので割愛しています。

表示する画像とドラッグする画像データは兼用とするためweatherImageプロパティを追加しました。
そのままで表示すると小さすぎるのでresizable()指定を追加し、幅と高さをframe(width:height:alignment:)モディファイアーで50に指定しました。
onDragでNSItemProvider(object: self.weatherImage )を渡すだけです。
(Xcode 12以降はself.を省略できます)

weatherImageプロパティはUIImage型なのでそのままNSItemProvider(object:)の引数に渡すことができます。


4 ドロップに対応する

ドロップ操作でデータを受け取る場合に重要な注意があります。
データを受け取る処理は非同期です。
このため受け取ったデータを使って画面を更新するには忘れずにメインのスレッドに戻さなければなりません

まずテキストデータのドロップを受け取るサンプルです。

// サンプル3 Textのドロップを受ける
struct TextDropDelegate: DropDelegate {
  @Binding var dropedString: String?

  func performDrop(info: DropInfo) -> Bool {
     guard info.hasItemsConforming(to: ["public.text"]) else {
        return false
     }
     let items = info.itemProviders(for: ["public.utf8-plain-text"])
     for item in items {
        _ = item.loadObject(ofClass: String.self) { str, _ in
           if let str = str {
              DispatchQueue.main.async {
                 self.dropedString = str
              }
           }
        }
     }
     return true
  }
}

struct Sample11View: View {
  @State private var dropedText: String? = nil
  let str = "「SwiftUIとドラッグ&ドロップ」サンプル"

  var dropText: some View {
     Rectangle()
        .stroke(Color.red)
        .background(Color(UIColor.systemBackground))
        .frame(width: 200, height: 70)
        .onDrop(of: ["public.text", "public.utf8-plain-text"], delegate: TextDropDelegate(dropedString:$dropedText))
        .overlay(
           Group {
              if dropedText != nil {
                 Text(dropedText!)
              }
           }
        )
  }

  var body: some View {
     VStack {
        Text(str)
           .onDrag{
              NSItemProvider(object: self.str as NSString)
           }
        Text("上の文字を赤枠にドラッグ")
           .font(.body)
           .padding()

        dropText
     }
  }
}

ドロップに対応するのはonDrop(of:delegate:)モディファイアーです。

このサンプルではbodyの行数が多くならないようにdropTextビューをプロパティーで作っています。
bodyと同じ計算型プロパティーです。

ドロップで受け取ったテキストはoverlayで表示しています。
プロパティーdropedTextが値なしでない場合だけ表示するようにGroupにしています。

4-1 ドロップ関係の型 DropInfo

関係する型が多いので順に説明します。

DropInfo型はSwiftUIフレームワークの型です。
現在の座標などドロップ状態を管理します。

DropInfo型の locationプロパティー

var location: CGPoint { get }

ドロップ時のタッチ位置です。
ドロップされたデータをドロップ位置にレイアウトする場合に利用できます。

DropInfo型の hasItemsConforming(to:)メソッド

func hasItemsConforming(to contentTypes: [UTType]) -> Bool
func hasItemsConforming(to types: [String]) -> Bool

引数に指定した型を持っているか確認します。
一つでも持っていればtrueが返ります。
String型配列を引数にとるメソッドはDepreacated(廃止予定)ですが、UTTypeが使えるのはiOS14以降なのでご注意ください。
今回はXcode 11.7でも実行できるようにString型配列を使っています。

DropInfo型の itemProviders(for:)メソッド

func itemProviders(for contentTypes: [UTType]) -> [NSItemProvider]
func itemProviders(for types: [String]) -> [NSItemProvider]

引数に指定した型のアイテムプロバイダーを取り出します
得られたNSItemProviderインスタンスから実際の値にアクセスできます。
こちらもString型配列を引数にとるメソッドはDepreacated(廃止予定)ですが、UTTypeが使えるのはiOS14以降なのでご注意ください。

4-2 ドロップ関係の型 UTI と UTType

"public.text"などどのようなデータかを示すための情報があります。
"public.text"のような書き方はUTI(Uniform Type Identifiers)です。

UTIはペーストされたデータ形式を識別するために導入されました。
文字列ゆえの問題もあり新たにUTTypeがiOS14から導入されます。

この記事ではXcode 11.7でも動作するようにUTIを使っています。
UTIの文字列には英字(大文字・小文字)数字、ドット、ハイフンを使うことができます。

下記UTIの資料は古い情報です。
残念ながらiPadのSafariでは本文を表示できません。

UTIの資料 Introduction to Uniform Type Identifiers Overview(英文)

システム宣言のUTI一覧 System-Declared Uniform Type Identifiers(英文)

"public.text"、 "public.utf8-plain-text"、 "public.image"、 "public.png"、 "public.jpeg"などは上記資料に載っています。

一方 UTType は iOS 14 から導入された新しい型で、目的はUTIと同じです。

Structure UTType(英文)
System Declared Types(英文)UTTypeの一覧できるドキュメントです。

UTIの解説とUTTypeとの関係を含むビデオがありました。

英語のビデオですが日本語字幕付きです。
ファイルタイプをUTIで表せるとの説明部分(冒頭の7分ほど)で明確な説明があります。
Developerアプリの日本語タイトル『Uniform Type Identifierの再導入』。

4-3 ドロップ関係の型 DropDelegate

DropDelegate型はCocoaでお馴染みのデリゲートのしくみをSwiftUIで使うためのプロトコルです。

DropDelegateは、SwiftUIのビューはそのままにして、自分のコードでドラッグ&ドロップをカスタマイズするためのプロトコルです。

5つのメソッドが必須ですが、そのうち4つはデフォルトの実装が用意されていてドロップに対応するには performDrop(info:) メソッドを実装すればドロップしたデータを受け取ることができます。
これらのメソッドでドラッグ中とドロップ時の挙動の制御や処理を行います。

❶ dropEntered(info:)メソッド 
ドラッグがビューに入ったことをデリゲートに通知します。


実装が必須のメソッドですがデフォルトの実装が用意されているので省略できます。

func dropEntered(info: DropInfo)

デフォルトの実装では何もしません。

❷ dropExited(info:)メソッド 
ドラッグ操作がビューから出たことをデリゲートに通知します。

実装が必須のメソッドですがデフォルトの実装が用意されているので省略できます。

func dropExited(info: DropInfo)

デフォルトの実装では何もしません。

❸ dropUpdated(info:)メソッド
ドラッグ中にビュー内で移動したことをデリゲートに通知します。

実装が必須のメソッドですがデフォルトの実装が用意されているので省略できます。

func dropUpdated(info: DropInfo) -> DropProposal?

デフォルトの実装は nil を返します。
DropProposal型インスタンスはcancel・copy・forbidden・moveの挙動のうちどれかをあらわします。
(copyとforbiddenの場合はそれぞれのバッジを表示します)
nilは最後に返した有効な値を利用するようにし、nilのみであればコピーとして処理されます。

❹ validateDrop(info:)メソッド 
期待されるタイプのいずれかに適合するアイテムを含むドロップが、ドロップを受け入れるビューに入ったことをデリゲートに通知します。


実装が必須のメソッドですがデフォルトの実装が用意されているので省略できます。
このメソッドはXcode 11.7、Xcode 12.2、Swift Playgrounds 3.4では実装しても呼び出されません

func validateDrop(info: DropInfo) -> Bool

デフォルトの実装は常にtrueを返します。

❺ performDrop(info:)メソッド 
ドロップされた時にシステムが呼び出すメソッドです。
ここでドロップされたデータを取り出して利用します。
実装が必須のメソッドです。(デフォルトの実装はないので自分のコードで実装が必要です)

func performDrop(info: DropInfo) -> Bool

ドロップが成功した場合はtrue、そうでない場合はfalseを返します。
引数はDropInfoのインスタンスだけです。

4-4 DropDelegateを使い文字列をドロップ可能にする

ここから先は

16,765字 / 6画像 / 1ファイル
この記事のみ ¥ 500

今後も記事を増やすつもりです。 サポートしていただけると大変はげみになります。