20200929のiOSに関する記事は12件です。

UIViewRepresentableを使ってSwiftUIでちょっとリッチなWebviewを表示してみる

はじめに

iOS14時点でのSwiftUIでは現在、WKWebViewに相当するViewのサポートを行っておりません。
そのため、UIKitのWKWebViewをSwiftUI用にラップして使用することが必要となります。
今回は基本的な機能(ツールバー、プログレスバー)を搭載したWebViewを表示させることをゴールとし、その中で出てくるUIViewRepresentableの使い方についてまとめていきたいと思います。

※ 本記事では汎用性を考え、iOS13まででサポートされている技術を使用してWebViewの表示を行います。

UIViewRepresentableについて

UIKitのViewをSwiftUIで使用するにはUIViewRepresentableを使用する必要があります。
UIViewRepresentableとはSwiftUIにてUIKitのViewを使用するためのラッパーです。
UIViewRepresentableのプロトコルで定義されている各関数について説明します。

func makeUIView(context: Self.Context) -> Self.UIViewType

実装必須。
表示するViewのインスタンスを生成します。
SwiftUIにて使用したUIKitのViewを戻り値として返却します。

func updateUIView(Self.UIViewType, context: Self.Context)

実装必須。
アプリの状態が更新される場合に呼ばれます。
Viewの更新がある場合は、本関数の中に記述します。

static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

指定したUIKitのViewが削除される際に呼び出されます。
必要に応じて登録した通知の削除など、クリーンアップ処理を本関数内に記述します。

func makeCoordinator() -> Self.Coordinator

View側から通知すべきイベントがある場合に実装します。
Coordinatorを定義することで、Delegateのようなユーザの操作によるイベントハンドリングを行うことができるようになります。

WKWebViewを使ってWebViewを作る

では本題のWKWebViewの表示に入っていきます。

WKWebViewを表示する

まずは単純にWebViewをSwiftUIで表示する方法です。
UIViewTypeassociatedtypeになりますので、ここをラップしたいUIKitのViewの型に変更します。
今回はWKWebViewにします。

WebView.swift
struct WebView: UIViewRepresentable {
    /// 表示するView
    private let webView = WKWebView()
    /// 表示するURL
    let url: URL

    func makeUIView(context: Context) -> WKWebView {
        // 戻り値をWKWebViewとし、返却する
        webView.load(URLRequest(url: url))
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) { }
}

ツールバーを作成する

次に戻る 進む リロードの3つの要素を持ったツールバーを作成していきます。

ボタンタップ時のアクションの制御

まずはツールバーを作成し、各ボタンを配置します。
次にProperty Wrappersを使用して、ツールバーにて各ボタンがタップされた際にWebView側で検知できるようにします。
この状態でツールバーのボタンをタップすると、アプリの状態を更新され、UIViewRepresentableupdateUIView(_ uiView:, context:)が発火します。
上記から、WebView側ではupdateUIView(_ uiView:, context:)の中にボタンに応じたアクションを記述します。

WebView.swift
struct WebView: UIViewRepresentable {
    // 省略...

    /// WebViewのアクション
    enum Action {
        case none
        case goBack
        case goForward
        case reload
    }

    /// アクション
    @Binding var action: Action

    func updateUIView(_ uiView: WKWebView, context: Context) {
        /// バインドしている値が更新されるたびに呼ばれる
        /// actionが更新されたら、更新された値に応じて処理を行う
        switch action {
        case .goBack:
            uiView.goBack()
        case .goForward:
            uiView.goForward()
        case .reload:
            uiView.reload()
        case .none:
            break
        }
        action = .none
    }
}
WebToolBarView.swift
struct WebToolBarView: View {
    /// アクション
    @Binding var action: WebView.Action

    var body: some View {
        VStack() {
            HStack() {
                // タップしたボタンに応じてアクションを更新
                Button("Back") { action = .goBack }
                Button("Forward") { action = .goForward }
                Button("Reload") { action = .reload }
            }
        }
    }
}
RichWebView.swift
struct RichWebView: View {
    /// URL
    let url: URL
    /// アクション
    @State private var action: WebView.Action = .none

    var body: some View {
        VStack() {
            WebView(url: url,
                    action: $action)
            WebToolBarView(action: $action)
        }
    }
}

@State@BindingなどのProperty Wrappersについては、本題から逸れるためここでは解説しません。
詳しく解説されている記事が多く存在しますので、必要に応じて別途ご参照下さい。
State and Data Flow | Apple Developer Documentation

ボタンの非活性化

これで、WebViewでボタンタップ時に処理をさせることが可能になりました。
しかし、どんな状態でもボタンがタップできてしまいますので、前後のページに移動できない場合は各ボタンを非活性化させるようにします。

Coordinatorを定義する
前後のページへ移動できるかどうかをWebViewのページ読み込みが完了したタイミングで判断するようにします。
そのためにはWKNavigationDelegateを実装する必要がありますが、直接WebViewに実装することはできません。
そこでUIViewRepresentableでは、CoordinatorというUIKitのViewから受け取った変更をSwiftUIに伝えるためのカスタムインスタンスを作成する必要があります。
WKNavigationDelegateCoordinatorに実装し、それを通してSwiftUI側のイベントハンドリングを行います。

WebView.swift
struct WebView: UIViewRepresentable {
    // 省略... 

    /// 戻れるか
    @Binding var canGoBack: Bool
    /// 進めるか
    @Binding var canGoForward: Bool

    func makeCoordinator() -> WebView.Coordinator {
        return Coordinator(parent: self)
    }
}

extension WebView {
    final class Coordinator: NSObject, WKNavigationDelegate {
        /// 親View
        let parent: WebView

        init(parent: WebView) {
            self.parent = parent
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            parent.canGoBack = webView.canGoBack
            parent.canGoForward = webView.canGoForward
        }
    }
}

あとはRichWebViewを介してWebToolBarViewにて各値を受け取り、各ボタンの非活性、活性の処理を記述すれば完了です。

WebToolBarView.swift
    Button("Back") { action = .goBack }.disabled(!canGoBack)
    Button("Forward") { action = .goForward }.disabled(!canGoForward)

プログレスバーを作成する

最後にプログレスバーを作成しましょう。

KVOで値を取ってくる

プログレスバーを作成するためにWKWebViewestimatedProgressisLoadingを取得する必要があります。
今回はそれぞれKVOを使用して取ってきます。
Viewからの変更通知を受け取ることになりますので、先ほども使用したCoordinatorを使用します。

WebView.swift
struct WebView: UIViewRepresentable {
    // 省略... 

     /// 読み込みの進捗状況
    @Binding var estimatedProgress: Double
    /// ローディング中かどうか
    @Binding var isLoading: Bool

    static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
        // WKWebView削除時に呼ばれます
        // インスタンスが削除されるタイミングで通知を無効化、削除しておきます
        coordinator.observations.forEach({ $0.invalidate() })
        coordinator.observations.removeAll()
    }
}

extension WebView {
    final class Coordinator: NSObject, WKNavigationDelegate {
        /// 親View
        let parent: WebView
        /// NSKeyValueObservations
        var observations: [NSKeyValueObservation] = []

        init(parent: WebView) {
            self.parent = parent
            // 通知を登録する
            let progressObservation = parent.webView.observe(\.estimatedProgress, options: .new, changeHandler: { _, value in
                parent.estimatedProgress = value.newValue ?? 0
            })
            let isLoadingObservation = parent.webView.observe(\.isLoading, options: .new, changeHandler: { _, value in
                parent.isLoading = value.newValue ?? false
            })
            observations = [
                progressObservation,
                isLoadingObservation
            ]
        }
        // 省略...
    }
}

プログレスバーを作成する

これでプログレスバーに必要なパーツは揃いました。
あとはProgressBarView.swiftを作成し、RichWebViewを介して値を受け取って表示すれば完了です。

ProgressBarView.swift
struct ProgressBarView: View {
    /// 読み込みの進捗状況
    var estimatedProgress: Double

    var body: some View {
        VStack {
            GeometryReader { geometry in
                Rectangle()
                    .foregroundColor(Color.gray)
                    .opacity(0.3)
                    .frame(width: geometry.size.width)
                Rectangle()
                    .foregroundColor(Color.blue)
                    .frame(width: geometry.size.width * CGFloat(estimatedProgress))
            }
        }.frame(height: 3.0)
    }
}
RichWebView.swift
struct RichWebView: View {
    // 省略...

    /// 読み込みの進捗状況
    @State private var estimatedProgress: Double = 0.0
    /// ローディング中かどうか
    @State private var isLoading: Bool = false

    var body: some View {
        VStack() {
            if isLoading {
                ProgressBarView(estimatedProgress: estimatedProgress)
            }
            // 省略...
        }
    }
}

これで一通りの機能を実装することができました。
エラーハンドリングなど別途考慮する点はありますが、一旦ざっくりとした機能を持ったWebViewを作成することができたのではないでしょうか。

おわりに

今回、リッチなWebViewの表示を目指して実装を行いましたが、SwiftUIでWebViewを実装するためにはUIViewRepresentableの基本的な機能を使用して作成する必要があるため、勉強にちょうど良いと思います。
興味ある方はぜひやってみてください。
ソースに関しては下記のgithubにまとめておきますので興味がある方がいらっしゃいましたら見ていただけると幸いです。
(github側はナビバーのタイトル表示や、レイアウトのための制約などここでは省いたコードがいくつか入っています。)
RichWebViewSample

また、今回の実装について改善案やご意見等ありましたらコメントいただけますと幸いです。
どうぞよろしくお願いいたします。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode 12 で自作 framework ビルド時に lipo コマンドがエラーを吐くようになった

現象

iOS用に自作の framework を fat でビルドしていました。(おそらくここをコピペ。)
Xcode 12 に上げたところ、下記のようなエラーが出るようになってしまいました。

fatal error: lipo:
 ***/Debug-iphonesimulator/***.framework/***
 and
 ***/Debug-iphoneos/***.framework/*** 
 have the same architectures (arm64) and can't be in the same fat output file

※ 見やすいように伏字と改行を入れています。

原因

Xcode 12 から、iPhoneシミュレーターも arm64 向けにビルドするようになったようで、従来実機にのみ含まれていたのが両方に含まれるようになり、コンフリクトしてしまったようです。

参考: Stackoverflow: iOS 14, lipo error while creating library for both device and simulator

解決策

以下は、とりあえず従来と同じように、シミュレーターに arm64 向けビルドが含まれないようにする方法です。 Apple Silicon 搭載の mac だと動作しない可能性があります。

(1) ビルドスクリプトの中から lipo -create ... の行を探す。
(自分の場合、 Build Phase の Run Script に lipo コマンドを含んだビルドスクリプトを記述しています。)

例(見やすさのために改行)
lipo -create -output \
"${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework/${PROJECT_NAME}" \
"${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${PROJECT_NAME}.framework/${PROJECT_NAME}" \
"${BUILD_DIR}/${CONFIGURATION}-iphoneos/${PROJECT_NAME}.framework/${PROJECT_NAME}"

(2) その上の行に、iphonesimulatorのビルドから arm64 を除去する記述を追加。

例(見やすさのために改行)
lipo \
-remove arm64 \
"${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${PROJECT_NAME}.framework/${PROJECT_NAME}" \
-output \
"${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${PROJECT_NAME}.framework/${PROJECT_NAME}"

最後に

上述しましたが、おそらく将来の Apple Silicon 搭載の mac では動作しません。Xcode 11 から使える XCFramework を検討する必要がありそうです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

FlutterやってみたよPart7(retrofit導入)

初めに

ほとんどのアプリがapi通信をすると思います。
なんか楽にクライアント生成できるのないかな〜と調べていたらretrofitというライブラリを見つけました。
今回はそれを導入してみようと思います。

サンプルアプリ

まずはサンプルアプリの仕様をざっくり決めます。

  • Qiitaのapiを使用して最新記事を取得する
  • 取得した記事を一覧で表示する
  • 記事のタイトルをタップしたら記事詳細をwebviewで開く

今回はこんな感じの簡単なアプリにします!

retforitの仕組み

導入する前にざっくりとどういう仕組みで動くのか理解します。
公式のReadmeやSampleを見ればなんとなくわかると思いますが、
abstractでapiのエンドポイントを定義。
この定義されたファイルを元にクライアントの実体を自動生成する仕組みです。

自動生成されるファイルは.g.dartとgがつくのが慣例のようです。
(多分generateのgかな?)
生成されるファイル名はpart句で宣言します。

pubspec.yaml

お決まりのyaml定義。
※バージョンを固定する場合はanyを書き換えてください。

dependencies:
  http: any
  retrofit: ^1.3.4
  json_annotation: ^3.0.1

dev_dependencies:
  retrofit_generator: any
  json_serializable: any
  build_runner: any

apiクライアントのabstract

// qiita_client.dart

part 'qiita_client.g.dart';  // これが自動生成される実体のファイル名

// ここにbaseUrlを定義(引数で上書きできるようになってます)
@RestApi(baseUrl: "https://qiita.com/api")  
abstract class QiitaClient {
  // dioの説明は割愛しますm(_ _)m
  // ここはまだ実体(_QiitaClient)がないのでエラーになったままです。
  // 自動生成すると、qiita_client.g.dartの中に_QiitaClientができます
  factory QiitaClient(Dio dio, {String baseUrl}) = _QiitaClient; 

  @GET("/v2/items")
  Future<List<QiitaArticle>> fetchItems(
      @Field("page") int page,
      @Field("per_page") int perPage,
      @Field("query") String query);

}

リクエスト・レスポンスのデータクラス定義

今回はレスポンスだけ定義します。

// qiita_article.dart

part 'qiita_article.g.dart';

// クラスの中に独自クラスがあって展開する場合はexplicitToJson:trueにします。
// ここではQiitaUserという独自クラスがあるのでtrueにしてます。
@JsonSerializable(explicitToJson: true) 
class QiitaArticle {
  // JsonKeyでjsonの名前を定義します。同じなら省略できます。
  @JsonKey(name: 'rendered_body')
  String renderedBody;
  String body;
  bool coediting;
  @JsonKey(name: 'comments_count')
  int commentsCount;
  @JsonKey(name: 'created_at')
  DateTime createdAt;
  String group;
  String id;
  @JsonKey(name: 'likes_count')
  int likesCount;
  bool private;
  @JsonKey(name: 'reactions_count')
  int reactionsCount;
  List<QiitaTag> tags;
  String title;
  @JsonKey(name: 'updated_at')
  DateTime updatedAt;
  String url;
  QiitaUser user;
  @JsonKey(name: 'page_views_count')
  int pageViewsCount;

  QiitaArticle({
    this.renderedBody,
    this.body,
    this.coediting,
    this.commentsCount,
    this.createdAt,
    this.group,
    this.id,
    this.likesCount,
    this.private,
    this.reactionsCount,
    this.tags,
    this.title,
    this.updatedAt,
    this.url,
    this.user,
    this.pageViewsCount,
  });

}

自動生成を走らせる前は余計なコード(factryや定数とかゲッターとか)は書かないことをお勧めします。
何かしらのエラーが発生するとファイルが生成されなかったです。

自動生成

ファイルの準備が終わったらターミナルで以下コマンドを実行します。

flutter pub run build_runner build

正常に終了すると.g.dartがひょこっと出てきます。
image.png

マッピング関数追加

自動生成されたのでjson→クラス、factoryを追加してあげます。

part 'qiita_article.g.dart';

@JsonSerializable(explicitToJson: true)
class QiitaArticle {
  @JsonKey(name: 'rendered_body')
  String renderedBody;
  String body;
  bool coediting;
  @JsonKey(name: 'comments_count')
  int commentsCount;
  @JsonKey(name: 'created_at')
  DateTime createdAt;
  String group;
  String id;
  @JsonKey(name: 'likes_count')
  int likesCount;
  bool private;
  @JsonKey(name: 'reactions_count')
  int reactionsCount;
  List<QiitaTag> tags;
  String title;
  @JsonKey(name: 'updated_at')
  DateTime updatedAt;
  String url;
  QiitaUser user;
  @JsonKey(name: 'page_views_count')
  int pageViewsCount;

  QiitaArticle({
    this.renderedBody,
    this.body,
    this.coediting,
    this.commentsCount,
    this.createdAt,
    this.group,
    this.id,
    this.likesCount,
    this.private,
    this.reactionsCount,
    this.tags,
    this.title,
    this.updatedAt,
    this.url,
    this.user,
    this.pageViewsCount,
  });

  // ↓ 追記
  factory QiitaArticle.fromJson(Map<String, dynamic> json) => _$QiitaArticleFromJson(json);
  Map<String, dynamic> toJson() => _$QiitaArticleToJson(this);

  @override
  String toString() => json.encode(toJson());
  // ↑ 追記
}

apiクライアントを使う

できたクライアントを実際に使ってみます。
今回はクライアントを生成するリポジトリを作って呼び出すことにします。

Statusコードも欲しいのでApiResponseというクラスに変換して返却することにしました。
(これ本来はClient側でやるべきかもしれません)

class QiitaRepository {

  final QiitaClient _client;

  QiitaRepository([QiitaClient client]):
        // オプショナルの第2引数でbaseUrlを変更できる
        // QiitaClient(Dio(), "http://127.0.0.1:8081") という感じ
        _client = client ?? QiitaClient(Dio())  
  ;

  Future<ApiResponse> fetchArticle(int page, int perPage, String query) async {

    return await _client.fetchItems(page, perPage, query)
        .then((value) =>  ApiResponse(ApiResponseType.OK, value))
        .catchError((e) {
          // エラーハンドリングについてのretrofit公式ドキュメント
          // https://pub.dev/documentation/retrofit/latest/
          int errorCode = 0;
          String errorMessage = "";
          switch (e.runtimeType) {
            case DioError:
              // 失敗した応答のエラーコードとメッセージを取得するサンプル
              // ここでエラーコードのハンドリングると良さげ
              final res = (e as DioError).response;
              if (res != null) {
                errorCode = res.statusCode;
                errorMessage = res.statusMessage;
              }
              break;
            default:
          }
          // ・・・ 省略 ・・・
        });
  }

}

// 共通のレスポンスクラスとして定義
// resultはdynamicにしとく。(使う側でcastする)
class ApiResponse {

  final ApiResponseType apiStatus;
  final dynamic result;
  final String customMessage;

  ApiResponse(this.apiStatus, this.result, this.customMessage);

}

// ここは必要に応じて定義
enum ApiResponseType {
  OK,
  BadRequest,
  Forbidden,
  NotFound,
  MethodNotAllowed,
  Conflict,
  InternalServerError,
  Other,
}

呼び出してみる

今回はChangeNotifier使ってるのでViewModel側に呼び出し部分をコーディングしました。

class HomeScreenViewModel with ChangeNotifier {

  QiitaRepository _qiitaRepository;
  List<QiitaArticle> articles = [];

  HomeScreenViewModel([QiitaRepository qiitaRepository]) {
    _qiitaRepository = qiitaRepository ?? QiitaRepository();
  }

  Future<bool> fetchArticle() async {
    return _qiitaRepository.fetchArticle(1, 20, "qiita user:Qiita")
        .then((result) {
          if (result == null || result.apiStatus!= ApiResponseType.OK) {
            // TODO: 何かしらのエラー処理

            // 画面に変更通知
            notifyListeners();
            return false;
          }

          // 結果を配列にadd
          articles.addAll(result.result);
          // 画面に変更通知
          notifyListeners();
          return true;
        });
  }
}

画面側のリストはこんな感じで単純にリストに表示するようにしました。

// ・・・ 省略 ・・・
ListView.builder(
  key: Key(WidgetKey.KEY_HOME_LIST_VIEW),
  itemBuilder: (BuildContext context, int index) {

    var length = context.read<HomeScreenViewModel>().articles.length -1;

    // 最終行まできたら
    if (index == length) {
      // 追加読み込みの関数をcall
      context.read<HomeScreenViewModel>().loadMore(context);
      // 画面にはローディング表示しておく
      return new Center(
        child: new Container(
          margin: const EdgeInsets.only(top: 8.0),
          width: 32.0,
          height: 32.0,
          child: const CircularProgressIndicator(),
        ),
      );
    } else if (index > length) {
      // ローディング表示より先は無し
      return null;
    }

    // データがあるので行アイテムを作成して返却
    return Container(
      child: rowWidget(context, index),
      alignment: Alignment.bottomLeft,
      decoration: BoxDecoration(
          border: Border.all(color: Colors.grey)
      ),
    );
  },
)
// ・・・ 省略 ・・・

できました!
image.png

終わりに

面倒なapi通信の実体の部分を自動生成してくれるので結構楽チンでした。
baseUrlも差し替えられるのでモック化も問題無くできそうな気はします。(まだ試してないです)
使っていて問題があるようでしたら追記していこうと思います。

最終的なサンプルプロジェクトはこちら
※少しづつ手を加えてるのでこちらに記載したコードと違うところがあります。

次は単体テスト・ウィジェットテスト・結合テストについて調べてみようと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Firebase AnalyticsをiOSに組み込む

自分用メモ

Google Analyticsが終了し、FirebaseにAnalyticsが移ったので
そっち経由でアプリに導入する。

手順

概要をこの記事を読んで同じことする
https://apps-gcp.com/introduction-of-firebase-analytics/

つまった

Xcode側のコンソールには表示されるのに、なぜかFirebaseのDebug Viewに表示されない

解決策

Product->Edit Schemaから以下のタグを追加
-FIRAnalyticsDebugEnabled
-FIRAnalyticsDebugEnabled

無事表示される。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS 14からUNNotificationPresentationOptionsの「.alert」が「.banner」と「.list」に分かれた

はじめに

プッシュ通知の表示オプション( UNNotificationPresentationOptions )に .alert がありますが、iOS 14までで非推奨になりました。
代わりに .banner.list の2つに分かれ、これらの挙動を調べたので紹介します。

環境

  • Xcode:12.0 (12A7209)
  • Swift:5.3
  • iOS:14.0

プッシュ通知の挙動

以下の表の通りです。

.alert ではバナーと通知センターの両方に通知が来ましたが、iOS 14からは .banner.list を使うことでどちらか片方のみに通知が来るようにできます。

UNNotificationPresentationOptions バナー 通知センター
.alert Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.56.50.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.56.34.png
.banner Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.56.50.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.57.48.png
.list Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.58.49.png Simulator Screen Shot - iPhone SE (2nd generation) - 2020-09-29 at 13.56.34.png

実装

iOS 13以前も引き続き対応する場合、OSのバージョンで分岐させるのがベターです。

AppDelegate.swift
extension AppDelegate: UNUserNotificationCenterDelegate {
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        if #available(iOS 14.0, *) {
            completionHandler([[.banner, .list, .sound]])
        } else {
            completionHandler([[.alert, .sound]])
        }
    }
}

おまけ:シミュレータでプッシュ通知を試す

iOSのシミュレータにプッシュ通知を送るのは、以下の記事が役立ちました。
https://qiita.com/koogawa/items/85c0dd0abd2f1970c5fc

こちらの記事のおかげで、プッシュ通知をかんたんに送って試すことができました。

おわりに

iOS 14からは通知の表示を細かく制御できるようになったことがわかりました。
.list のみ指定することでAndroidのサイレント通知のように使えたりと、使いこなせると便利そうです。

もし便利な使いみちがありましたら、コメントなどで教えていただけると嬉しいです :relaxed:

参考リンク

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】WidgetKit使用時に、入力された文字をUserDefaults経由でWidgetに表示する方法

前回こちらの記事【Swift】iOS14.0から追加されたWidgetKitを簡単に実装する方法
で、WidgetKitを使用する方法を記載しましたが、
今回はWidgetKitをExtensionとして追加後、UserDefaults経由で値を共有する方法を記載します。

環境

  • Xcode: 12.0
  • Swift5

下準備

※前提として既にTargetからWidgetKitが追加されていることとします

1. CapabilityからAppGroupを追加

TargetをWidgetApp(WidgetKitを追加した元となるProject)に選択し、CapabilityからAppGroupをダブルクリックしAppGroupを追加します。
スクリーンショット 2020-09-29 11.47.46.png
以下のようにAppGroupsの欄が追加されていればOK
スクリーンショット 2020-09-29 11.58.32.png

2.AppGroupsに共通のIdentifierを登録する

a.AppGroupsの「+」を押下すると以下のようなダイアログが表示されるので、開発チームを選択する
スクリーンショット 2020-09-29 12.02.01.png

b.AppGroupsで使用する共通のIdentifierの入力を求めらるので以下のように入力する

group.{プロジェクト作成時のBundle Identifier}.xxxxx

例)
スクリーンショット 2020-09-29 12.00.20.png

※注意

xxxxx部分は自由に登録可能ですが、xxxxx.yyyのように二つ以上ピリオドで繋げてしまうと、
AppStore申請時にエラーで弾かれてしまいますので注意してください。

OK押下後、以下のようにAppGroup用のIdentifierが登録されていればOKです。
※チェックボックスにチェックがついていない場合は、付けるようにしてください。
スクリーンショット 2020-09-29 12.00.36.png

3.WidgetExtension側にもAppGroupsを追加する

ほぼ手順は先ほど行った手順と同じです。

a.Extensionとして追加したTarget(WidgetAppExtension)を選択し、Capbility>AppGroupsを追加する
スクリーンショット 2020-09-29 13.19.15.png

b. 「+」ボタンを押下しAppGroupsに共通Identifierを登録する
こちら側は、開発者チームを選択したところで終了するはずです。
そうすると手順2で追加したIdentifierが表示されていると思うので、チェックボックスにチェックマークをつけてください。
スクリーンショット 2020-09-29 12.02.22.png

以上でUserDefaultsを使用する下準備が終了です。

実装

今回は、以下のような内容の実装をしていきます。
・画面側で入力された文字列をUserDefaultsに保存
・UserDefaultsに保存された文字列をWidgetに表示する

1.画面入力用のテキストフィールド、ボタンを配置する

ContentView.swift
struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
            }){
                Text("文字を保存する")
            }
        }
    }
}

2.UserDefaultsに保存処理を追加

今回はボタン押下時にテキストフィールドの値を保存します

ContentView.swift
struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
                let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
                if let userDefaults = userDefaults {
                    userDefaults.synchronize()
                    userDefaults.setValue(text, forKeyPath: "inputText")
                }
            }){
                Text("文字を保存する")
            }
        }
    }
}

このようにsuiteNameに先ほど登録したIdentifierを指定することによってUserDefaultsの値を共有することができます。
userDefaults.synchronize()は不要との記事も見かけましたが、私は追加しないと動作しませんでした。

3.UserDefaultsから値取得処理

WidgetAppExtension.swift
    // struct Provider: TimelineProvider内
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        /* 追記ここから  */
        var text = ""
        let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
        if let userDefaults = userDefaults {
            text = userDefaults.string(forKey: "inputText") ?? ""
        }
        /* ここまで  */
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

値保存時と同様に、IdentifierをsuiteNameに指定し、他は普段通りのUserDefaultsの使用方法と同じです。

4.WidgetにUserDefaultsから取得した値を表示する

前回の記事から何も触っていなければ、
デフォルトのWidgetには時間が表示される処理が記載されていると思うので、
そちらに追加で、文字列が表示されるようにしてみます。

WidgetAppExtension.swift
import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), text: "")
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), text: "")
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        var text = ""
        let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
        if let userDefaults = userDefaults {
            text = userDefaults.string(forKey: "inputText") ?? ""
        }
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            // UserDefaultsから取得した文字列をセット
            let entry = SimpleEntry(date: entryDate, text: text)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let text: String
}

struct WidgetAppExtensionEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
        Text(entry.text)
    }
}

@main
struct WidgetAppExtension: Widget {
    let kind: String = "WidgetAppExtension"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            WidgetAppExtensionEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

struct WidgetAppExtension_Previews: PreviewProvider {
    static var previews: some View {
        WidgetAppExtensionEntryView(entry: SimpleEntry(date: Date(),text: ""))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

以上で、UserDefaultsに保存された値をWidgetKitに表示させることができます。

ボタン押下後すぐにWidgetの表示内容を更新したい場合

ボタン押下時に以下処理を呼び出すことで更新が可能となります。

WidgetCenter.shared.reloadAllTimelines()
ContentView.swift
import SwiftUI
import WidgetKit

struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
                // AppGroups追加時に設定したIdentifier
                let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
                if let userDefaults = userDefaults {
                    userDefaults.synchronize()
                    userDefaults.setValue(text, forKeyPath: "inputText")
                }
                // Widgetを更新
                WidgetCenter.shared.reloadAllTimelines()
            }){
                Text("文字を保存する")
            }
        }
    }
}

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

動作

0929gif2.gif

このように入力した文字がWidgetKitに反映されるようになりました!
何か私の認識違い等ございましたらご指摘いただけますと幸いです。

備考

アプリ公開しました!よろしければインストールお願いします。
とらんぽ

Twitter始めました!よろしければフォローお願いします。
@yajima_tohshu

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】WidgetKit(SwiftUI)使用時にAppGroupsでUserDefaultsの値を共有する方法

前回こちらの記事【Swift】iOS14.0から追加されたWidgetKitを簡単に実装する方法
で、WidgetKitを使用する方法を記載しましたが、
今回はWidgetKitをExtensionとして追加後、UserDefaults経由で値を共有する方法を記載します。

環境

  • Xcode: 12.0
  • Swift5

下準備

※前提として既にTargetからWidgetKitが追加されていることとします

1. CapabilityからAppGroupを追加

TargetをWidgetApp(WidgetKitを追加した元となるProject)に選択し、CapabilityからAppGroupをダブルクリックしAppGroupを追加します。
スクリーンショット 2020-09-29 11.47.46.png
以下のようにAppGroupsの欄が追加されていればOK
スクリーンショット 2020-09-29 11.58.32.png

2.AppGroupsに共通のIdentifierを登録する

a.AppGroupsの「+」を押下すると以下のようなダイアログが表示されるので、開発チームを選択する
スクリーンショット 2020-09-29 12.02.01.png

b.AppGroupsで使用する共通のIdentifierの入力を求めらるので以下のように入力する

group.{プロジェクト作成時のBundle Identifier}.xxxxx

例)
スクリーンショット 2020-09-29 12.00.20.png

※注意

xxxxx部分は自由に登録可能ですが、xxxxx.yyyのように二つ以上ピリオドで繋げてしまうと、
AppStore申請時にエラーで弾かれてしまいますので注意してください。

OK押下後、以下のようにAppGroup用のIdentifierが登録されていればOKです。
※チェックボックスにチェックがついていない場合は、付けるようにしてください。
スクリーンショット 2020-09-29 12.00.36.png

3.WidgetExtension側にもAppGroupsを追加する

ほぼ手順は先ほど行った手順と同じです。

a.Extensionとして追加したTarget(WidgetAppExtension)を選択し、Capbility>AppGroupsを追加する
スクリーンショット 2020-09-29 13.19.15.png

b. 「+」ボタンを押下しAppGroupsに共通Identifierを登録する
こちら側は、開発者チームを選択したところで終了するはずです。
そうすると手順2で追加したIdentifierが表示されていると思うので、チェックボックスにチェックマークをつけてください。
スクリーンショット 2020-09-29 12.02.22.png

以上でUserDefaultsを使用する下準備が終了です。

実装

今回は、以下のような内容の実装をしていきます。
・画面側で入力された文字列をUserDefaultsに保存
・UserDefaultsに保存された文字列をWidgetに表示する

1.画面入力用のテキストフィールド、ボタンを配置する

ContentView.swift
struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
            }){
                Text("文字を保存する")
            }
        }
    }
}

2.UserDefaultsに保存処理を追加

今回はボタン押下時にテキストフィールドの値を保存します

ContentView.swift
struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
                let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
                if let userDefaults = userDefaults {
                    userDefaults.synchronize()
                    userDefaults.setValue(text, forKeyPath: "inputText")
                }
            }){
                Text("文字を保存する")
            }
        }
    }
}

このようにsuiteNameに先ほど登録したIdentifierを指定することによってUserDefaultsの値を共有することができます。
userDefaults.synchronize()は不要との記事も見かけましたが、私は追加しないと動作しませんでした。

3.UserDefaultsから値取得処理

WidgetAppExtension.swift
    // struct Provider: TimelineProvider内
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        /* 追記ここから  */
        var text = ""
        let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
        if let userDefaults = userDefaults {
            text = userDefaults.string(forKey: "inputText") ?? ""
        }
        /* ここまで  */
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

値保存時と同様に、IdentifierをsuiteNameに指定し、他は普段通りのUserDefaultsの使用方法と同じです。

4.WidgetにUserDefaultsから取得した値を表示する

前回の記事から何も触っていなければ、
デフォルトのWidgetには時間が表示される処理が記載されていると思うので、
そちらに追加で、文字列が表示されるようにしてみます。

WidgetAppExtension.swift
import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), text: "")
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), text: "")
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        var text = ""
        let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
        if let userDefaults = userDefaults {
            text = userDefaults.string(forKey: "inputText") ?? ""
        }
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            // UserDefaultsから取得した文字列をセット
            let entry = SimpleEntry(date: entryDate, text: text)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let text: String
}

struct WidgetAppExtensionEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
        Text(entry.text)
    }
}

@main
struct WidgetAppExtension: Widget {
    let kind: String = "WidgetAppExtension"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            WidgetAppExtensionEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

struct WidgetAppExtension_Previews: PreviewProvider {
    static var previews: some View {
        WidgetAppExtensionEntryView(entry: SimpleEntry(date: Date(),text: ""))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

以上で、UserDefaultsに保存された値をWidgetKitに表示させることができます。

ボタン押下後すぐにWidgetの表示内容を更新したい場合

ボタン押下時に以下処理を呼び出すことで更新が可能となります。

WidgetCenter.shared.reloadAllTimelines()
ContentView.swift
import SwiftUI
import WidgetKit

struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
                // AppGroups追加時に設定したIdentifier
                let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
                if let userDefaults = userDefaults {
                    userDefaults.synchronize()
                    userDefaults.setValue(text, forKeyPath: "inputText")
                }
                // Widgetを更新
                WidgetCenter.shared.reloadAllTimelines()
            }){
                Text("文字を保存する")
            }
        }
    }
}

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

動作

0929gif2.gif

このように入力した文字がWidgetKitに反映されるようになりました!
何か私の認識違い等ございましたらご指摘いただけますと幸いです。

備考

アプリ公開しました!よろしければインストールお願いします。
とらんぽ

Twitter始めました!よろしければフォローお願いします。
@yajima_tohshu

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Swift】iOS14で追加されたWidgetKit(SwiftUI)使用時に、AppGroupsでUserDefaultsの値を共有する方法

前回こちらの記事【Swift】iOS14.0から追加されたWidgetKitを簡単に実装する方法
で、WidgetKitを使用する方法を記載しましたが、
今回はWidgetKitをExtensionとして追加後、UserDefaults経由で値を共有する方法を記載します。

環境

  • Xcode: 12.0
  • Swift5

下準備

※前提として既にTargetからWidgetKitが追加されていることとします

1. CapabilityからAppGroupを追加

TargetをWidgetApp(WidgetKitを追加した元となるProject)に選択し、CapabilityからAppGroupをダブルクリックしAppGroupを追加します。
スクリーンショット 2020-09-29 11.47.46.png
以下のようにAppGroupsの欄が追加されていればOK
スクリーンショット 2020-09-29 11.58.32.png

2.AppGroupsに共通のIdentifierを登録する

a.AppGroupsの「+」を押下すると以下のようなダイアログが表示されるので、開発チームを選択する
スクリーンショット 2020-09-29 12.02.01.png

b.AppGroupsで使用する共通のIdentifierの入力を求めらるので以下のように入力する

group.{プロジェクト作成時のBundle Identifier}.xxxxx

例)
スクリーンショット 2020-09-29 12.00.20.png

※注意

xxxxx部分は自由に登録可能ですが、xxxxx.yyyのように二つ以上ピリオドで繋げてしまうと、
AppStore申請時にエラーで弾かれてしまいますので注意してください。

OK押下後、以下のようにAppGroup用のIdentifierが登録されていればOKです。
※チェックボックスにチェックがついていない場合は、付けるようにしてください。
スクリーンショット 2020-09-29 12.00.36.png

3.WidgetExtension側にもAppGroupsを追加する

ほぼ手順は先ほど行った手順と同じです。

a.Extensionとして追加したTarget(WidgetAppExtension)を選択し、Capbility>AppGroupsを追加する
スクリーンショット 2020-09-29 13.19.15.png

b. 「+」ボタンを押下しAppGroupsに共通Identifierを登録する
こちら側は、開発者チームを選択したところで終了するはずです。
そうすると手順2で追加したIdentifierが表示されていると思うので、チェックボックスにチェックマークをつけてください。
スクリーンショット 2020-09-29 12.02.22.png

以上でUserDefaultsを使用する下準備が終了です。

実装

今回は、以下のような内容の実装をしていきます。
・画面側で入力された文字列をUserDefaultsに保存
・UserDefaultsに保存された文字列をWidgetに表示する

1.画面入力用のテキストフィールド、ボタンを配置する

ContentView.swift
struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
            }){
                Text("文字を保存する")
            }
        }
    }
}

2.UserDefaultsに保存処理を追加

今回はボタン押下時にテキストフィールドの値を保存します

ContentView.swift
struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
                let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
                if let userDefaults = userDefaults {
                    userDefaults.synchronize()
                    userDefaults.setValue(text, forKeyPath: "inputText")
                }
            }){
                Text("文字を保存する")
            }
        }
    }
}

このようにsuiteNameに先ほど登録したIdentifierを指定することによってUserDefaultsの値を共有することができます。
userDefaults.synchronize()は不要との記事も見かけましたが、私は追加しないと動作しませんでした。

3.UserDefaultsから値取得処理

WidgetAppExtension.swift
    // struct Provider: TimelineProvider内
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        /* 追記ここから  */
        var text = ""
        let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
        if let userDefaults = userDefaults {
            text = userDefaults.string(forKey: "inputText") ?? ""
        }
        /* ここまで  */
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }

値保存時と同様に、IdentifierをsuiteNameに指定し、他は普段通りのUserDefaultsの使用方法と同じです。

4.WidgetにUserDefaultsから取得した値を表示する

前回の記事から何も触っていなければ、
デフォルトのWidgetには時間が表示される処理が記載されていると思うので、
そちらに追加で、文字列が表示されるようにしてみます。

WidgetAppExtension.swift
import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), text: "")
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), text: "")
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        var text = ""
        let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
        if let userDefaults = userDefaults {
            text = userDefaults.string(forKey: "inputText") ?? ""
        }
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            // UserDefaultsから取得した文字列をセット
            let entry = SimpleEntry(date: entryDate, text: text)
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let text: String
}

struct WidgetAppExtensionEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        Text(entry.date, style: .time)
        Text(entry.text)
    }
}

@main
struct WidgetAppExtension: Widget {
    let kind: String = "WidgetAppExtension"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            WidgetAppExtensionEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

struct WidgetAppExtension_Previews: PreviewProvider {
    static var previews: some View {
        WidgetAppExtensionEntryView(entry: SimpleEntry(date: Date(),text: ""))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

以上で、UserDefaultsに保存された値をWidgetKitに表示させることができます。

ボタン押下後すぐにWidgetの表示内容を更新したい場合

ボタン押下時に以下処理を呼び出すことで更新が可能となります。

WidgetCenter.shared.reloadAllTimelines()
ContentView.swift
import SwiftUI
import WidgetKit

struct ContentView: View {
    @State private var text = ""
    var body: some View {
        VStack {
            TextField("文字入力", text: $text)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .padding(.horizontal)
            Button(action: {
                // ボタン押下時
                // AppGroups追加時に設定したIdentifier
                let userDefaults = UserDefaults(suiteName: "group.com.sample.yajima.WidgetApp.WidgetExtension")
                if let userDefaults = userDefaults {
                    userDefaults.synchronize()
                    userDefaults.setValue(text, forKeyPath: "inputText")
                }
                // Widgetを更新
                WidgetCenter.shared.reloadAllTimelines()
            }){
                Text("文字を保存する")
            }
        }
    }
}

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

動作

0929gif2.gif

このように入力した文字がWidgetKitに反映されるようになりました!
何か私の認識違い等ございましたらご指摘いただけますと幸いです。

備考

アプリ公開しました!よろしければインストールお願いします。
とらんぽ

Twitter始めました!よろしければフォローお願いします。
@yajima_tohshu

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

モバイルアプリにおけるUIデザイン

1.はじめに

私は(筆者は)モバイルエンジニア転職を目指す立場です。個人的にアプリのUIデザイン知識はデザイナーだけでなくエンジニアにも必須だと思っています。私自身がアプリ開発の過程でデザインについて指摘され、「このままデザインに無知では良くないと思った」、「同じような立場の方にモバイルアプリのデザインの事を少しでも知ってもらいたいと思った」その2つの理由から本記事を執筆しました。
現在、エンジニアを目指している方やアプリ開発はしているけどモバイルデザインについて改めて知りたいという方、そのような方向けに改めてiOS・Android双方のデザインガイドラインについてまとめてみました。

2.iOSとAndroidのデザインの考え方

2020年現在、iOSではHuman Interface Guidelines、AndroidではMaterial Designの考え方をもとにアプリUIが作られています。双方の公式ページはモバイルアプリを構成する UI の基本指針が細かく記されているのでエンジニア&デザイナー必読です。AppleよりGoogleの方が画像や映像を多く使って解説しているため、本記事もGoogle/Material Designの方が視覚的な解説が多いことをご容赦いただければ幸いです。HIGではMAterial Designほど、どう実装するかという細かいことは明記されておらず、ユーザーにとってUIがどうあるべきかを内容としています。一方、マテリアルデザインは各コンポーネントをどうするべきか、詳細に明記していることが内容に含まれています。
※あくまでもモバイルアプリでのUI前提の記事なので、本記事はWebやWindows(other OSを含む)のUIについては記載しません。
※本記事の情報は2020年9月現在の情報を元に執筆しています。

●ヒューマンインターフェースガイドライン(iOS)

まずはAppleのHuman Interface Guidelines(ヒューマン・インターフェース・ガイドライン、以下「HIG」)から。以下公式ページです。
https://developer.apple.com/design/human-interface-guidelines/ios/overview/themes/

iOS7以降、Appleのフラットデザイン1に基づいたデザイン指針がHPに記載されています。AppleはiPhone登場以来、スキューモフィズム2の代表と言えるような、現実世界に近づけたアイコンやUIを採用してきました。そこからiOS7で大きくデザインを変更しました。新たにHIGをアップデートし、フラットデザインを取り入れたガイドラインとして発表しました。平面的なデザインとそこにシャドウ(影)をつけたデザインで奥行きを再現し、可能領域を認識しやすくしています。

当時、AppleのiOS責任者であったジョナサン・アイブ氏はインタビューで

「人々がタッチスクリーンにすでに慣れて、十分使いこなしているため、物理的なボタンを模倣するようなことはもう必要ないと考えた。」

と語っています。
今日まで、そのHIGを元にiOSのアプリデザインは設計されています。

HIG1.png

Appleの目指すデザインには「3つの主要なテーマ」「6つのデザイン原則」、そして
主要な9つの項目」
があります。

○3つの主要なテーマ

1.Clarity(明快さ)

・テキストは様々なサイズで読みやすいテキスト
・正確で明快なアイコン
・装飾は巧妙で適切
・機能性を重視した動き
・余白、色、フォント、グラフィックス、インターフェイス要素は、重要なコンテンツを違和感なく強調し、ユーザーにインタラクティブ性があることを伝えられること

2.Deference([機能・コンテンツへの]敬意)

・滑らかな動きと美しいインターフェイスによって、ユーザーはコンテンツを理解し、操作を可能にする
・通常、コンテンツは画面全体に表示されるが、半透明性とぼやかし(ブラー)は、多くの場合でヒントになる
・ベゼル、グラデーション、ドロップシャドウの使用を最小限に抑えることで、インターフェイスを軽く風通しの良いものに保ちながら、コンテンツを最優先に(重要視する)にする

3.Depth(奥行き)

・はっきりした視覚的階層とリアルな動きが(コンテンツの)重なりを伝え、活気のある動きを与え、ユーザーへ理解しやすくする
・ユーザー体験は触れたり見つけやすくすることでより良いものとなり、文脈を見失うことなく機能や次のコンテンツに、アクセスすることができる
・トランジション(変化)はユーザーが奥行きを理解する助けとなる

○6つのデザイン原則

1.Aesthetic Integrity(美的完成度)

外観と中身の整合性は、アプリの見た目と動作が機能とどれだけうまく統合されているかを表しています。
例えば、ToDoアプリは、落ち着いて邪魔にならないグラフィック、標準化された予測できる操作でないといけない。一方、ゲームアプリは、楽しそうで魅力的な外観でないといけない。

2.Consistency(一貫性)

一貫性があるアプリは、システムが提供する馴染みのある規格、知られたアイコン、スタンダードなテキストスタイル、統一された用語、アプリの形や雰囲気を実装している。アプリにはその手法の特徴や挙動を取り入れる。

3.Direct Manipulation(直接操作)

画面上のコンテンツを直接操作することで、ユーザーを引き込み、何が起きているかを理解しやすくする。直接操作を行うことで、ユーザーは自分が取ったアクションの結果をすぐにはっきりと理解できるようになる。例えば、要素の並べ替えでは直接指でドラッグして移動できるようにする。

4.Feedback(フィードバック)

フィードバックはユーザーが取ったアクションを認識し、アクションによって起こった結果を伝える役割がある。内蔵されたiOSアプリはすべてのユーザーアクションに応答して、認識可能なフィードバックを提供します。
インタラクティブなUIはタップされたときにすぐにハイライト表示され、時間のかかる操作のステータスはプログレスバー3が表示される。アニメーションやサウンドはアクションの結果をはっきりと示すのに役立つ。

5.Metaphors(比喩の使用)

アプリの仮想オブジェクトとアクションが、現実世界とデジタル世界のどちらに根付いているかにかかわらず、慣れ親しんだ体験の比喩である場合、人々はより早く学習する。
人々は物理的に画面を操作するため、メタファー(現実世界に存在する物質や素材をUIデザインに落とし込むこと)はiOSでうまく機能する。それらはビューを邪魔にならないようにし、コンテンツを下に公開する。
スイッチを切り替えたり、スライダーを動かしたり、ピッカーの値をスクロールしたりすることである。それらは、本や雑誌のページもめくります。

6.User Control(ユーザーによる制御)

iOS全体では、アプリではなくユーザーがアプリをコントロールする。
アプリは一連のアクションを提案したり、危険である結果について警告したりするが、通常、アプリが意思決定を行うのは間違いである。良いアプリは、ユーザーのコントロール範囲を増やすことと、望まない結果を避けることの間でちょうど良いバランスを見つけます。
アプリは、インタラクティブな要素を使い慣れた予測可能な状態に保ち、(コンテンツが)削除されたことが確認でき、(処理中だとしても)操作を簡単にキャンセルできるようにすることで、ユーザーは自分たちがそのアプリをコントロールしている感覚を得る。

○3つの主要インターフェース

ほとんどのiOSアプリはプログラミングフレームワークであるUIKitのコンポーネントを使用して構築されています。
Apple(iOS)ではUIKitを元に、インターフェース要素を大きく「ナビゲーションバー」「ビュー」「コントロール」「3つ」に分けて考えます。今回は、その中から各要素の解説をいくつか例で紹介します。

1.ナビゲーションバー

スクリーンショット 0002-09-25 17.36.02.png
(出典:Human Interface Guidelines/Apple)

今、アプリ内のどこにいるのかをユーザーに伝え、ナビゲーションを提供します。アクションを開始したり、情報を伝達したりするためのボタンやその他の要素を配置することができます。

2.ビュー

テキスト、グラフィック、アニメーション、インタラクティブな要素など、アプリでユーザーが目にする主要なコンテンツを含みます。ビューは、スクロール、挿入、削除、配置などの動作を可能にします。
スクリーンショット 0002-09-25 18.08.23.png
(出典:Human Interface Guidelines/Apple)
上記画像はアクションシートです。
アクションシートを使用して、作業を開始したり、削除する操作を実行する前に確認を要求したりできます。

スクリーンショット 0002-09-25 22.58.54.png
(出典:Human Interface Guidelines/Apple)
上記画像はテーブルビューです。
左画像が基本(デフォルト)。行の左側にあるオプションの画像の後に、左揃えのタイトルが続きます。これは、補足情報を必要としないアイテムを表示するのに適したオプションです。右画像は字幕。1行に左揃えのタイトル、次の行に左揃えのサブタイトル。このスタイルは、行が視覚的に類似しているテーブルでうまく機能します。

3.コントロール

画面へのタッチによりアクションを開始し、情報を伝えます。ボタン、スイッチ、テキストフィールド、進行状況インジケーターは、コントロールの例です。

スクリーンショット 0002-09-25 18.11.28.png
(出典:Human Interface Guidelines/Apple)
上記画像は、システムボタンです。
ボタンタイトルには動詞を使用します。ボタンアクションのタイトルは、ボタンがインタラクティブであることを示し、タップすると何が起こるかを示しています。

スクリーンショット 0002-09-25 19.17.10.png
(出典:Human Interface Guidelines/Apple)
上記画像は、進捗インジケーターです。
アクティビティインジケーターを使用して、アプリが停止していないことをユーザーに知らせ、待機時間を知らせます。

○主要な9つの項目

HIGではユーザーにとって最善の体験価値を提供するため、アプリ設計、各動作、視覚的なデザイン、各コンポーネントがどうあるべきかが明記されています。解説内容がとても多い為、本記事では一部例のみ抜粋し紹介します。

1.アプリの設計
2.ユーザーインタラクション(相互作用)
3.システム機能
4.ビジュアルデザイン
5.アイコンと画像
6.バー
7.ビュー
8.コントロール
9.拡張機能

・アプリの設計

◎許可を求めることについて
ユーザーは、アプリが現在地、カレンダー、連絡先、リマインダー、写真などの個人情報にアクセスする場合、その事を許可しなければなりません。
スクリーンショット 0002-09-29 3.03.41.png
(出典:Human Interface Guidelines/Apple)

例えば、上の画像では現在の位置情報を取得して良いか許可を求めています。現在地の情報を反映させ、天気をより正確にアプリ内に表示させるため位置情報を利用するからです。
本文では「ユーザーは自分の物理的な位置情報を写真に自動的にタグ付けしたり、近くの友達を見つけたりすることができるのが好きですが、ユーザーは個人データをコントロールできるオプションがあることも望んでいます。」と書かれています。
「個人データを要求するのは、アプリが明らかに必要としている場合に限ります。」と書かれているように、プライバシー保護の観点から許可を求める際のガイドラインが書かれています。許可を求める場合にはアプリに情報が必要な理由をアラート内で説明して下さいと明記されています。

・ビジュアルデザイン

◎色について

色は、活力を与え、視覚的な連続性を提供し、ステータス情報を伝え、ユーザーのアクションに応じてフィードバックを与え、データを視覚化するのに最適な方法であることが説明されています。明るい背景と暗い背景の両方で、組み合わせて見栄えの良いアプリの色合いを選択します。下記URL内の各項目で、色についてどこに気をつければよいのかが書かれています。
https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/color/

・コミュニケーションのための色は慎重に
・アプリ全体で補色を使用
・一般的には、アプリのロゴとコーディネートする限定的なカラーパレットを選択する
・アプリ全体のインタラクティブ性を示す色合いを選択することを検討する
・明るいモードと暗いモードの両方で見栄えが良いことを確認するために、2つのバージョンの色合いを用意する
・インタラクティブ要素と非インタラクティブ要素に同じ色を使用しない
・アートワークと半透明度が近隣の色にどのように影響するかを考慮する
・さまざまな照明条件でアプリの配色をテストする
・True Tone ディスプレイがカラーにどのように影響するかを検討する
・色の使用が他の国や文化でどのように認識されるかを考慮する
・アプリのコンテンツを認識しづらい色を使用しないようにする

・システム機能

◎通知
アプリは通知を使用して、デバイスがロック中であっても使用中であっても、いつでもタイムリーに重要な情報を提供することができます。例えば、通知はメッセージが到着したとき、イベントが発生しようとしているとき、新しいデータが利用可能になったとき、または何かのステータスが変更されたときに通知することができます。人々は、ロック画面、デバイスを使用中の画面上部、通知センターで通知を見ることができます。
スクリーンショット 0002-09-29 3.50.05.png
(出典:Human Interface Guidelines/Apple)

上の画像ような通知はローカルでもリモートでも表示できるようになっています。例えば、Todoリストアプリでは、ローカル通知を使用して、予定されている会議や期日について通知(警告)することができます。
リモート通知は、プッシュ通知とも呼ばれています。これはサーバーから送られてきます。例えば、マルチプレイヤーゲームでは、自分の出番が来たときにリモート通知を使用してプレイヤーに知らせることがあります。
また、優れた通知体験を提供するために以下の様な注意事項が明記されています。

・簡潔で読みやすい通知を作成し、有益な情報を提供する
・通知には、機密情報、個人情報、機密情報を含めないようにする
・ユーザーが応答していない場合でも、同じ内容の通知を複数回送信しないようにする
・通知のプレビューが非表示になっているときに表示されるように、説明的で非特定のテキストを提供する
・アプリ名やアイコンを含めない
・通知を補足するためにサウンドを提供することを検討する
・詳細ビューの提供を検討する
・直感的で有益なアクションを提供する
・破壊的なアクションを提供しないようにする

○ユーザインターフェイスのデザインのヒント

これはAppleが公式で日本語ページにて公開しているものなので、下記URLからチェックしてみて下さい。
https://developer.apple.com/jp/design/tips/#clarity

本記事では一部抜粋して紹介します。

・タッチコントロール

スクリーンショット 0002-09-26 1.11.59.png
(出典:ユーザインターフェイスのデザインのヒント/Apple

タッチコントロールはガイドライン内、コントロール-ボタンを参照しています。
右の✖画像はカレンダーの表示が小さく、どこをタッチすればよいか分からなくなってしまっています。右の✔画像の様に、アプリ内で簡単かつ自然に操作できるようにしなければなりません。

・文字の大きさ

スクリーンショット 0002-09-26 2.09.12.png
(出典:ユーザインターフェイスのデザインのヒント/Apple

文字の大きさはガイドライン内、ビジュアルデザイン-タイポグラフィを参照しています。
右の✖画像は表示されている文字が小さく、読むことが困難です。最低でも文字は11ポイント以上のサイズに設定し、ズームしなくても読めるようにしなければなりません。

通常の閲覧距離でもズームすることなく快適に読めるようにしなければなりません。また、フォント色とビューの背景色とのコントラストが悪い場合や、文字の間隔が狭い場合は可読性が失われるため、ガイドラインを参考に適切に設定しなければなりません。

●マテリアルデザイン(Android)

次に、2014年にGoogleが発表したマテリアルデザインです。以下、公式サイトです。
ガイドラインについてはサイト内、Material Guidelinesを御覧ください。
https://material.io/

スクリーンショット 0002-09-24 14.46.41.png

マテリアルデザインは平面的な質感をもたせつつ、物質の重なりを意識させるためにシャドウを落として奥行きを強く感じさせるものになっています。「紙」と「インク」の要素でできているという考えを持っており、2014年の発表時から今日まで変わりません。
目的は大きく分けて2つあり、1つ目は「どんなデバイスでも共通化されたデザインを実現するため」、2つ目は「ユーザーに"直感的"な操作体系を提供するため」です。以上の2つを元に、マテリアルデザインは自然の世界を物理法則に従い、メタファー(隠喩)としてデジタルスクリーンに落とし込んでいます。また、コンテンツを多様な画面サイズで、変わりなく表示できるようデザインすることも、マテリアルデザインの目的としてあるようです。

上記のガイドラインに則って開発されているコンポーネント(ボタンやビュー)がマテリアルコンポーネントです。この基礎的なコンポーネントを最初から用意しておくことで、「車輪の再開発を止め、開発者やデザイナーがプロダクトのサービスやブランドの体験作りの方に注力できるようにして欲しい」というGoogle側の意図があります。

マテリアルデザイン(2014〜)

Googleの目指すデザインには当初「4つのデザイン原則」がありました。現在はアップデートされ3つに統一されています。しかし、現在のマテリアルデザインの元となっているものなので初期のデザイン原則も紹介しておきます。
マテリアル4つのデザイン原則.jpg

○4つのデザイン原則(2014年)

1.タンジブルサーフェス

RPReplay_Final1601012338-2.gif
(出典:Material Design/Google Developers)

マテリアルデザインでは画面は小さな四角い紙(英語ではサーフェス)によってできていると考えます。画面内に紙のような物があって、その上に何かが乗っているというように考えです。その特徴を上げると以下のようなものが分かります。

・紙と紙が離れる
・紙と紙がくっつく
・紙を動かす事が出来る
・高さの違う紙が重なっていると、その下の紙には影が出来る
ユーザーは自然に「上のものをどけたら下には何かがあるだろう」という発想になり、説明なくコンテンツを理解してもらえるようになっています。

ガイドラインページ:

マテリアルデザイン:Surfaces

2.印刷物のようなデザイン

マテリアルデザイン1.png
(出典:Material Design)

印刷物のデザインにはキーラインというものがあります。どこに揃えて文字を表示するかを考え、縦に引いたガイドラインに合わせてテキストや画像を配置するだけで、デザインが美しく見えるようになります。

3.意味があるアニメーション

マテリアルデザイン2.gif
(出典:Material Design)
マテリアルデザインではアニメーションを使いますが、それは意味のあるアニメーションであるべきと考えます。どこから来て、どこへ行ったのかを視覚的に見せることで、自分が今何をしているのかということが理解しやすくなります。

4.アダプティブデザイン

マテリアルデザイン3.png
(出典:Material Design/Google Developers)

今日、私達は5インチの小さな画面から、10インチを超える大きな画面サイズでコンテンツを操作します。画面サイズが変わっても、裏で使われているアプリやシステムは同じで、表示サイズによって画面サイズを適切な見え方にしようという考えです。

〜現在のマテリアルデザイン〜

ここから現在のマテリアルデザインについて説明します。現在、マテリアルデザインは「Material System(マテリアルシステム)」、「Material Foundation(マテリアルファンデーション)」、「Material Guidelines(マテリアルガイドライン)」の3つに分けて説明されています。1つ目のマテリアルシステムではデザイン3原則について解説されています。2つ目のマテリアルファンデーションでは、基礎となるグリッドやカラーパレット、コンポーネント全般について解説されています。3つ目のマテリアルガイドラインではMaterial Theming(マテリアルシーミング)とアクセシビリティ、Android向けガイダンスについて記載されています。

○3つの原則

Material Design - Introduction
ここではマテリアルデザインの3つの原則について明記されています。

1.材料はメタファーである

マテリアル4-1.png

マテリアルデザインは、物理的な世界とそのテクスチャ(特徴)から発想を得ています。これには、光を反射したり影を落としたりする方法も含まれます。各素材の表面は、紙とインクの媒体を再考します。

2.太字、グラフィック、意図的

マテリアル4-2.png

マテリアルデザインは、印刷デザイン手法(タイポグラフィ、グリッド、スペース、スケール、色、イメージ)を用いて、階層、意味、および焦点を形成し、視聴者を体験に没頭させます。

3.モーション(動き)は意味を与えます

マテリアル4-3.png

モーションは注意を集中させ、微妙なフィードバックと一貫した移行を通じて継続性を維持します。要素が画面に表示されると、インタラクションによって環境を変形させ、再編成し、新たな変形を生み出します。

○マテリアルファンデーション

ここでは基礎となるレイアウト、グリッド、カラー、アイコン、モーションなどを大きく12項目に分けてどうすれば良いかが明記されています。HIG同様、解説内容がとても多い為、本記事では一部例のみ抜粋し紹介します。
詳細は、Material Design - Foundationでご確認下さい。

1.環境(Environment)
2.レイアウト(Layout)
3.ナビゲーション(Navigation)
4.色(Color)
5.タイポグラフィ(Typography)
6.音(Sound)
7.アイコン(Icons)
8.形状(Shape)
9.動き(Motion)
10.相互作用(Interaction)
11.コミュニケーション(Confirmation)
12.機械学習(Machine learning)

・環境(Environment)

https://material.io/design/environment/surfaces.html#material-environment

◎面、奥行き、影について

要素は、水平方向、垂直方向、およびz軸に沿ってさまざまな深さで移動します。ここでは下の図を用いることで奥行きをZ軸で説明し、UIの操作はY軸であることを説明しています。
マテリアル5.png

・レイアウト(Layout)

https://material.io/design/layout/understanding-layout.html#
ここではレイアウトグリッドについて、間隔をどう開けたらよいかについてなどが説明されてます。
◎材料の測定
マテリアルデザインのレイアウトが視覚的にバランスが取れているのは8dpグリッドを基準として、その倍数に数値を合わせて設定していることが説明されています。アイコンなどの小さなコンポーネントは4dpに揃えることで、バランスを保ったレイアウトが調整されています。
マテリアル6.png

・ナビゲーション(Navigation)

ユーザーはアプリ内を移動します。その際の導線やタスク、動きなどの解説がされています。

◎ナビゲーションの方向(パターン)
ナビゲーション方向パターンは横方向のナビゲーション、フォワードナビゲーション、リバースナビゲーションの3種類に分けて説明し、音楽アプリの構造を例に説明されています。
本記事では横方向のナビゲーションを取り上げます。横方向のナビゲーションは、名前の通り横に展開していく構成で、同じ階層レベルの画面間を移動するナビゲーションのことです。アプリの主要なナビゲーションコンポーネントは、階層(レイヤー)の最上位にあるすべての動作先へのアクセスを提供する必要があります。
下の図ではアプリ構造のトップの階層にある、「Library」,「Recently Played」、「Search」の画面間を移動できます。
マテリアル7-1.png

スクリーンショット 0002-09-27 17.03.10.png
(出典:MaterialDesign - Understanding navigation)
(図表"公式ページより翻訳" by Masaki Sugita)

1.ナビゲーションドロワー

ナビゲーションドロワーは、5つ以上のトップレベルの目的地に適しており、一貫したナビゲーション体験のためにデバイスのサイズを超えて使用することができます
マテリアル7-2.png

2.下部ナビゲーションバー

下部のナビゲーションバーは、モバイルデバイス上でトップレベルの 3~5 の遷移先へのアクセスを提供します。これらのバーの位置、可視性、画面上での持続性により、遷移間のピボット(方向転換)を素早く行うことができます。
マテリアル7-3.png

3.タブ

タブは、アプリの階層のどのレベルでも使用でき、画面サイズをまたいで2つ以上のデータのピアセットを表示することができます。
マテリアル7-4.png

○マテリアルガイドライン

マテリアルガイドラインでは「マテリアルシーミング」「使いやすさ」「プラットフォームガイダンス」3つに分けて説明されています。2018年のガイドラインアップデートで登場したのがマテリアルシーミングです。
マテリアルデザインを採用すると、どのプロダクトも共通のデザイン(UI)になってしまい、似たりよったりのデザインが出来上がってしまうという問題がありました。その問題を解決したのが、マテリアルシーミングです。マテリアルシーミングを利用すると、プロダクトをカスタマイズしてコンポーネントで開発するプロセスが簡単になり、サービスを使いやすく、機能的にするためにコンポーネントを使用できます。コンポーネントは、各開発プロダクトに使用可能で機能的にするための構成要素です。

1.マテリアルシーミング

マテリアルシーミング は、プロダクトのブランドをより良く反映させるためにマテリアルデザインを体系的にカスタマイズしブランドに合わせて拡張できる機能です。UIの側面を変更すると、プロダクトのブランドをよりよく反映させることができます。アプリのすべてのモジュールのところまでカスタマイズ可能にしておいて、かつ全体のテーマ感を損なわないようになっています。

マテリアル8.gif
(出典:Material Design - Material Theming)

例えばボタンの例です。
ボタンを各プロダクトのブランド向けにカスタマイズしたい場合はマテリアルシーミングは、ボタンのような個々のコンポーネントを含む UI 全体に影響を与えます。この例では、ボタン コンポーネントの既成概念にとらわれない値をどのようにカスタマイズできるかを示しています。アプリ内でボタンの色や角Rをカスタマイズし、各画面に適用させるためにマテリアルシーミングを使用します。

スクリーンショット 0002-09-28 15.05.55.png

上の図では左側がデフォルトのボタンで、右側がカスタマイズされたボタンです。下記は左側のデフォルト設定。
・色は#6200EE
・テキストは14pt、Roboto、Mediumで全て大文字
・ボタンの角は4dpの丸い半径
このデフォルト設定から右画像のボタンに仕上げるまで、タイポグラフィー、色、図像(アイコン)、形状をどのように設定するかが記載されています。
https://material.io/design/material-theming/overview.html#using-material-theming

2.使いやすさ(アクセシビリティ)

デザインにおけるアクセシビリティは、多様な能力を持つユーザーがUIをナビゲートし、理解し、使用することを可能にします。ここでは、低視力、失明、聴覚障害、認知障害、運動障害、または状況障害(腕の骨折など)を含むすべてのユーザーの使いやすさについて記載されています。
例えばスクリーンリーダーです。

・スクリーンリーダー
視覚障害、読書困難、または一時的に文字が読めない人は、スクリーンリーダーを使用することがあります。スクリーン・リーダーは点字ディスプレイか、声を出しテキストを読み込んでくれるソフトウェアプログラムのトークバックを使用します。
スクリーンリーダーは、表示されているコンテンツを言語化して読み上げます。段落とボタンのテキスト、およびアイコンと見出しの代替テキストなどの非表示のコンテンツは、プログラムによって識別されます。コンテンツによってこの機能を使い分け、スクリーンリーダーを使用するユーザーエクスペリエンスを最適化できます。

その他、アクセシビリティからの視点で階層や色、タイポグラフィーに関して明記されていますので下記サイトから確認してみて下さい。
https://material.io/design/usability/accessibility.html#assistive-technology

3.プラットフォームガイダンス

ここでは、Androidアプリ内のナビゲーションバー、ハプティクス(接触)フィードバック、Android内の通知についてなどを、どのように実装すれば良いのかのガイドラインが14項目に分けて、明記されています。
https://material.io/design/platform-guidance/android-bars.html#status-bar
・バー
・指紋
・ハプティクス(接触)
・アイコン
・アプリ間の移動
・通知
・権限
・設定
・スライス
・分割画面
・スワイプして更新
・テキスト選択ツールバー
・ウィジェット
・クロスプラットホーム

3.まとめ

今回調査してみて、Appleは抽象的、Googleは具合的に書かれているということが分かりました。
共通点は、「使うユーザーが異なる画面サイズで操作しても、どんなユーザーが操作しても、同じユーザーエクスペリエンス(UX)ができるように設計することが大切だ」という考えを持っていることでした。OSによって根本的な考えが異なるとはいえ、世界のモバイルOSを牽引している2社のデザインガイドラインはユーザーが操作しやすく、プロダクトやブランドをどうしたらよく見せられるかということがしっかり明記されていました。本記事では解説が不十分なところがまだまだ多くあるので、今後調査しながらさらに情報をアップデートしていこうと思います。
皆様の開発に少しでもお役に立てれば幸いです。

4.参考記事/動画/書籍 一覧

Apple - Human Interface Guidelines
Google - Material Design

-HIG
ユーザインターフェイスのデザインのヒント
Apple ヒューマンインターフェースガイドライン輪読のすゝめ
iOS ヒューマンインターフェースの原則
「深津貴之氏に学ぶ、スマホUI/UX講座 〜iOS7についての考察とfladdictデザイン論〜」に参加してきました。
今さら人には聞けない、スキューモーフィズムとフラットデザインの違い
独学でUIデザインはじめた方へ。デザインガイドラインについて語ろう!
Macintosh から iPhone へ受け継がれるデザイン原則
常にキャッチアップしよう!iOSヒューマンインターフェイスガイドライン
2. マッキントッシュ, 3. ヒューマンインターフェイス ガイドライン [2020.6.2]

-Material Design
マテリアルデザインについて少し調べる
Material Designの設計思想を探る
I/O 2014 アプリに学ぶマテリアルデザイン
Android アプリにマテリアル デザインを導入する
マテリアルデザインに見る機能的なアニメーションの6つの法則
サンクスブログ Googleが発表したマテリアルデザイン
マテリアルデザインでよりよいユーザー体験を実現しよう
マテリアルデザインとは何か?- 最新Webデザイントレンド
【マテリアルデザイン採用】リニューアルで気をつけた4つの大事なポイント -feedly編-
Google, Apple, Audi ── デザインシステムのメニューを見比べれば、企業とデザインの関係がわかる
Material Theming 概要
12 Absolute Principles of Material Design

-動画
Material Theming - Material Design の先へ(Youtube)
DroidKaigi 2019 - マテリアルデザインの起源とベースとなる哲学 / ken(Youtube)

-書籍
フラットデザインで考える 新しいUIデザインのセオリー
UI GRAPHICS 成功事例と思想から学ぶ、これからのインターフェイスデザインとUX


  1. フラットデザインとは、できるだけ影やグラデーションなどの装飾を使わずに表現したシンプルで平面的なデザインのこと。Apple自体はフラットデザインというワードは使っていない。 

  2. 他の物質(現実世界の物)に似せるために行うデザインや装飾のこと。 ユーザーに馴染みのないものや初めて触るものを、現実世界の物質(物体)のようにデジタル上で再現することで、ユーザーに理解してもらい易くすくする手法。 

  3. 長時間かかるタスクの進捗状況がどの程度完了したのかを視覚的・直感的に表示するもの。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

画像分類モデルをCore MLに変換する

画像分類モデル変換はクラス・ラベルを指定する

スクリーンショット 2020-09-29 13.43.38.png

1,クラス・ラベル・ファイルを読み込む。

クラスラベルファイルはモデルの作成者から提供されていることが多いみたいです。

class_labels = open('labels.txt', 'r').read().splitlines()
print(class_labels)
### ['black', 'white', 'grey', ...] 

2,クラス・ラベルがString型の配列であることを確認しておきます。

for i, label in enumerate(class_labels):
  if isinstance(label, bytes):
     class_labels[i] = label.decode("utf8")

3,Core ML ToolsのClassifier Configという形式でクラス・ラベルを指定して変換します。

import coremltools as ct

image_input = ct.ImageType(shape=(1, 224, 224, 3,),
                           bias=[-1,-1,-1],
                           scale=2/255)

classifier_config = ct.ClassifierConfig(class_labels)

mlmodel = ct.convert(model,
                     inputs=[image_input],
                     classifier_config=classifier_config)

?


お仕事のご相談こちらまで
rockyshikoku@gmail.com

Core MLを使ったアプリを作っています。
機械学習関連の情報を発信しています。

Twitter
MLBoysチャンネル
Medium

相棒
note

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

TensorFlow1のモデルをCore MLに変換する

グラフからの変換

TensorFlow1からの変換は、
1、凍結グラフからの変換
2、グラフからの直接変換
があります。
1、の凍結グラフの作成方法については「AnimeGANv2をCore MLに変換してiOSでつかう」とおなじです。

TensorFlow1の変換

1、凍結グラフからの変換

mlmodel = ct.convert('inferrence.pb')

2、グラフからの直接変換

mlmodel = ct.convert(self.sess.graph)

グラフからの直接変換は、sess.runしているスクリプトの下に書き込んでしまうのがおすすめです。


お仕事のご相談こちらまで
rockyshikoku@gmail.com

Core MLを使ったアプリを作っています。
機械学習関連の情報を発信しています。

Twitter
MLBoysチャンネル
Medium

相棒
note

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS14でのCore Location変更点

iOS14でのCore Location

こんにちは、今回は業務でiOS14で変更になったCore Location周りの改修をしたので、備忘録的に書かせていただきます。
あんまり記事がなかったので、誰かの役に立てば幸いです!

環境

macOS Catalina 10.15.6
Xcode Version 12.0.1

Deprecated

まずDeprecatedになったものを調査しました。
対象は今回iOS14だと使えない物についてまとめます。

CLLocationManager

Requesting Authorization for Location Services

  • authorizationStatus()
class func authorizationStatus() -> CLAuthorizationStatus
// 位置情報サービスを利用するためのアプリの認証状況を返します。

Initiating Beacon Ranging Requests

func startRangingBeacons(in: CLBeaconRegion)
// 指定されたビーコン領域の通知の配信を開始します。

func stopRangingBeacons(in: CLBeaconRegion)
// 指定されたビーコン領域の通知の配信を停止します。

var rangedRegions: Set<CLRegion>
// 指定されたビーコン領域の通知の配信を停止します。

Deferring Location Updates

class func deferredLocationUpdatesAvailable() -> Bool
// デバイスが遅延ロケーション更新をサポートするかどうかを示すブール値を返します。

func allowDeferredLocationUpdates(untilTraveled: CLLocationDistance, timeout: TimeInterval)
// 指定された基準が満たされるまで、ロケーション更新の配信を延期するようにロケーションマネージャーに依頼します。

func disallowDeferredLocationUpdates()
// このアプリの位置情報の更新の延期をキャンセルします。

CLLocationManagerDelegate

Responding to Authorization Changes

  • Deprecated
func locationManager(CLLocationManager, didChangeAuthorization: CLAuthorizationStatus)
// アプリがロケーションマネージャーを作成したとき、および承認ステータスが変更されたときに、代理人に承認ステータスを通知します
  • New
func locationManagerDidChangeAuthorization(CLLocationManager)
// アプリがロケーション マネージャを作成したとき、および承認ステータスが変更されたときに、委任者に通知します。

Responding to Ranging Events

  • Deprecated
func locationManager(CLLocationManager, didRangeBeacons: [CLBeacon], in: CLBeaconRegion)
// 1つ以上のビーコンが範囲内にあることをデリゲートに通知します。
func locationManager(CLLocationManager, rangingBeaconsDidFailFor: CLBeaconRegion, withError: Error)
// 一連のビーコンのレンジング情報の収集中にエラーが発生したことをデリゲートに通知します。

ビーコン周りが結構変変更されてましたね。
余談ですが、
CLBeacon使ったことないし、何ができるかもわからんので、今度CLBeacon周り深く探ってみようかと思います。
登山とかするので、何かの役に立つかもしれないw

本題

さてさて、ここからは本題ですが、
今回のアップデートで特に注目されていたところとしては、
位置情報の許可をするときに、

  • 正確な位置情報

項目が追加さてたのが大きな変更かなと個人的には思っています。

なぜこうなったのかは、Appleさんがいろいろ考えてくれた感じなので、それは調べていただければ良いので、
今回はこの
- 正確な位置情報
に関して、Codeを踏まえて説明できたらと思います。

実装

これまでよくあった実装としては

let status = CLLocationManager.authorizationStatus() 
    switch status {
    case .authorizedWhenInUse:
    // ...
    case .authorizedAlways:
    // ... 
    case .denied:
    // ...
    case .notDetermined:
    // ...
    case .restricted:
    // ...
    case .authorized:
    // ...

こんな感じで statusごとの処置を書いていたと思いますが

'authorizationStatus()' was deprecated in iOS 14.0

    @available(iOS, introduced: 4.2, deprecated: 14.0)
    open class func authorizationStatus() -> CLAuthorizationStatus

となっていたので、authorizationStatus()は使えません。
ただし

public enum CLAuthorizationStatus : Int32 {}

こちらは使えますので、間違えないようにしたいですね。

こちらの変更としては

open class CLLocationManager : NSObject {
    // ...
    @available(iOS 14.0, *)
    open var authorizationStatus: CLAuthorizationStatus { get }
}

CLLocationManager に authorizationStatus が新たに追加された形となりました。

ここ、ややこしいので、気をつけておいた方が良さそうです。

下記Codeは CLLocationManagerの主な変更部分です。

open class CLLocationManager : NSObject {
    // ...
    @available(iOS 14.0, *)
    open var authorizationStatus: CLAuthorizationStatus { get }

    @available(iOS, introduced: 4.2, deprecated: 14.0)
    open class func authorizationStatus() -> CLAuthorizationStatus

    @available(iOS 14.0, *)
    open var accuracyAuthorization: CLAccuracyAuthorization { get }
    // アプリが使用を許可されている位置精度のレベルを示す値。

    @available(iOS 14.0, *)
    open var isAuthorizedForWidgetUpdates: Bool { get }
    // ウィジェットが位置の更新を受信する資格があるかどうかを示すブール値。

    @available(iOS 14.0, *)
    open func requestTemporaryFullAccuracyAuthorization(withPurposeKey purposeKey: String, completion: ((Error?) -> Void)? = nil)
    // 位置情報サービスを完全に正確に一時的に使用するためのユーザーの許可を要求します。

    @available(iOS 14.0, *)
    open func requestTemporaryFullAccuracyAuthorization(withPurposeKey purposeKey: String)
    // 位置情報サービスを完全に正確に一時的に使用するためのユーザーの許可を要求します。
}

ステータスの処理を分ける場合

func locationManager(CLLocationManager, didChangeAuthorization: CLAuthorizationStatus) {}

こちらがdeprecatedなので

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {}

を使って実装していきます。

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
 let status = manager.authorizationStatus
 switch status {
   case .authorizedAlways, .authorizedWhenInUse:
   // ...
   case .notDetermined, .denied, .restricted:
   // ...
   default: 
   // ...
 }
}

このような形でstatusに対する処理を分岐する形となります。

次に正確な位置情報についてです

public enum CLAccuracyAuthorization : Int {
    case fullAccuracy = 0     // 正確な位置情報 ON
    case reducedAccuracy = 1  // 正確位置情報  OFF
}

こちらが新規追加されました。

   switch manager.accuracyAuthorization {
   case .fullAccuracy:
       print("正確な位置情報")
   case .reducedAccuracy:
       print("曖昧な位置情報")
   default:

   }

実装としてはこのような形で分岐することが可能です。
非エンジニアの方に今回の変更を説明する場合
reducedAccuracyを[曖昧な位置情報]という言い方で説明したところ、理解してくれました。
公式によると 
(DeepL翻訳)

// The user has chosen to grant this application access to location information with reduced accuracy.
// Region monitoring and beacon ranging are not available to the application. Other CoreLocation APIs
// are available with reduced accuracy.
// Location estimates will have a horizontalAccuracy on the order of about 5km. To achieve the
// reduction in accuracy, CoreLocation will snap location estimates to a nearby point which represents
// the region the device is in. Furthermore, CoreLocation will reduce the rate at which location
// estimates are produced. Applications should be prepared to receive locations that are up to 20
// minutes old.

ユーザーはこのアプリケーションに、精度を落とした位置情報へのアクセスを許可することを選択しました。
地域モニタリングやビーコン測距はアプリケーションでは利用できません。 その他の CoreLocation API
は精度を落として利用できます。
位置推定の水平精度は約5kmのオーダーになります。 を達成するために
精度の低下を防ぐために、 // CoreLocation は位置推定値を近くの点にスナップします。
デバイスが存在する地域を指定します。 さらに、CoreLocation は、デバイスが存在する地域の
見積もりを作成しています。 アプリケーションは、20までの場所を受信するために準備してください。
分経過しています。

こんな感じで書いてあります。

業務で修正した主なところとしては正確な位置情報を取得して、いろいろと使っていたので、今回はここの修正を主に行いました。

まとめ

今回はCoreLocationの変更された部分にフォーカスして書かせていただきました。
アプリによっていろんなパターンがあるので、一概に上記の対応だけでは望める実装ができない場合があるかと思いますので、私が参考にした記事を貼り付けておきます。

iOS 14 でさらに強化された位置情報まわりのプライバシー
[iOS14]WWDC 2020 Core Location 新要素 Preciseについて

上記の記事を参考にいたしました。
この記事では書いていない
- requestTemporaryFullAccuracyAuthorizationの使い方
- info.plistについて
- AppleのMapアプリに関すること
- 標準アプリの挙動など
- 他諸々
などが記載されていたので、まとめて読んでおくと良さそうに思いました。

Apple公式
What's new in location
Core Location

読んでいただきありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む