20200205のiOSに関する記事は8件です。

#23 Viewの背景を画像にする方法

はじめに

個人のメモ程度の出来なのであまり参考にしないで下さい.

環境

Xcode:11.2.1
Swift:5.1.2
2019/11

part1

Assets.cassestsに画像をドロップする.

スクリーンショット 2020-02-05 午後10.39.49.png

part2

UIImageViewと検索する.

スクリーンショット 2020-02-05 午後10.40.32.png

part3

UIImageViewViewに全て覆うように置く.

スクリーンショット 2020-02-05 午後10.40.53.png

part4

UIImageViewに以下の4つの制約を付ける.

スクリーンショット 2020-02-05 午後10.41.08.png

part5

するとUIImageViewがズレるので,もう一度Viewに全て覆うようにする.

スクリーンショット 2020-02-05 午後10.41.28.png

part6

その状態で,Selected ViewsUpdate Constraint Constantsを選択する.

スクリーンショット 2020-02-05 午後10.41.37.png

part7

そのままUIImageViewを選択した状態で,Attributes inspectorImageから,先程追加した画像を選択する.

スクリーンショット 2020-02-05 午後10.41.59.png

part8

UIImageViewを選択した状態で,Attributes inspectorContent ModeScale To Fillを選択する.

スクリーンショット 2020-02-05 午後10.42.14.png

part9

ラベルやボタンよりも後にUIImageViewを配置すると,ラベルやボタンが隠れてしまうので,下の写真のように,UIImageViewSafe Areaの直下にドラッグ・ドロップする.

スクリーンショット 2020-02-05 午後11.04.53.png

スクリーンショット 2020-02-05 午後10.42.51.png

part10

以下のように,UIImageViewが再背面に移動している事を確認する.

スクリーンショット 2020-02-05 午後10.43.35.png

part11

Label Labelを選択して,Attributes inspectorShadowSystem Background Colorにする.

スクリーンショット 2020-02-05 午後10.44.04.png

part12

Label Labelを選択した状態で,Attributes inspectorShadow OffsetWidthHeight-1にする.

スクリーンショット 2020-02-05 午後10.44.38.png

part13

Button!を選択して,Attributes inspectorFontStyleBoldにする.

スクリーンショット 2020-02-05 午後10.45.07.png

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

iOSエンジニアがSign in with Appleをサーバーサイドで実装したときに行き詰りを感じたところとか

Sign in with Apple 実装のデッドラインは4月 ですね☺️
Web/iOS/Androidなど、複数プラットフォームをサポートするためWebViewでログイン機能を実装しているサービスや、外部サービスAPI利用の際に独自のAPIを噛ませているなど、サーバーサイドでSign in with Appleに対応するケースはあるかと思います。

iOSエンジニアであるわたしがサーバーサイドで実装した際に、わかりにくいな〜?と感じたポイントについて記載します。
自分のスキルセットとして、認証・認可、Webの知識はほぼ0でした?
実装したソースコードをの抜粋を載せていますが、言語はrubyです。

やること

今回ご説明する Sign in with Apple の全体のざっくりフローは、

  1. Apple Developer サイトでの設定
  2. Apple ID サインイン画面表示(Authorizeエンドポイント)、認可コード受取り
  3. 認可コードを使い、AppleのユーザーIDを取得(Tokenエンドポイント)

です。

取得したいデータがemailとnameのみの場合 → ステップ2まで
ユーザーIDを取得したい場合 → ステップ3まで
実装する必要があります。

参考までに前知識

Auth関連

まず最初に、OAuth周りの知識があると理解が早いと思います。
この方のqiitaが非常に参考になりました。(ありがとうございました。)

Appleガイドライン関連

Apple Developer サイトでのもろもろ

ではさっそくApple Developerサイトを開きます。

Servise ID作成からドメイン認証

  1. 対象の App ID を作成し、Capabilities として Sign in with Apple を追加する
  2. Apple Developer の Certificates, Identifiers & Profiles から Service ID を追加する
  3. Primary App ID を 1 のAppに指定する
  4. Domainsに認可コードのリダイレクト先のドメインと Return URLs を指定する
  5. Domainの検証ファイル apple-developer-domain-association.txt をダウンロードする
  6. Domainの検証ファイルをWebサイトの https://.../.well-known/ ディレクトリに置く

Service IDReturn URLs はAuthorize、Tokenエンドポイントで利用します。

ドメイン検証に失敗したら

  • https://{yourdomain}/.well-known/apple-developer-domain-association.txt にアクセスできるか
  • 検証ファイルは有効期限内か(7日間のみ有効)
  • 検証ファイルのダウンロードは何回でも可能なので、常に最新のファイルを使う
  • サーバーがTLSの条件を満たしているか

を確認する。

明文化されていなさそうですが、Appleさんからサポートを受けた開発者がフォーラムに書き込んでいます。
https://forums.developer.apple.com/thread/122124

そのほか、ネットワーク構成などに問題がないか等、公式の Troubleshooting Domain Verification を読んでみるとよいです。

Key をダウンロード

Tokenエンドポイントのリクエストパラメータの JSON Web Token 作成に使います。
当然ですが、センシティブなファイルなので暗号化などよしなに行い、サーバーのどこか安全なところに置いておく。
今回はわたしは、.txtに変換して使いました。

公式ドキュメントは Create a Sign in with Apple private key

Apple IDサインインページ表示

Appleサインインボタン

公式ドキュメント Incorporating Sign in with Apple into Other PlatformsAdd a Custom “Sign in with Apple” Button にダウンロードURLやサイズ、カラー指定のクエリパラメータの説明があります。
Human Interface Guidelines にしたがって正しくつかいましょう。

Authorizeエンドポイント

Appleの認証ページURLのクエリパラメータ、レスポンスについては Incorporating Sign in with Apple into Other Platforms に記載があります。

大事なのは、

client_id = Service IDで指定した文字列
redirect_uri = Service IDに設定したリダイレクトURI

です。

  • response_type = id_tokenにする場合、response_modefragmentまたはform_postである必要がある
  • 氏名やメールアドレスを取得したい(scopeを指定する)場合、response_mode = form_postにする必要がある

など、諸々の制約があります。

サインインページが表示されると、ユーザーによるApple IDの認証を受け付けます。
認証が完了すると、指定したリダイレクトURIにリダイレクトが行われます。
受け取れる値は認可コードと、scopeに指定がある場合初回1度のみユーザーID以外の個人情報(メールアドレス、氏名など)が受け取れます。

サインインページがうまく表示されない

invalid_uriinvalid_client が表示されたら、焦らず client_idredirect_uri を今一度確認しましょう。
開発・本番など環境別でドメインを分けている場合、 redirect_uri が変わると思いますので要チェックです。
Apple Developer 上で作成した Service ID の Returns URLs に該当のリダイレクト先が登録されているか確認しましょう。

アクセストークン・ユーザーID取得

Tokenエンドポイント

Appleのトークン発行エンドポイントにリクエストを行い、トークンの発行を行います。
パラメータ、レスポンスについては Generate and validate tokens に記載があります。
パラメータは Authorization のものを使います。

client_secretの値のJSON Web Token(JWT)作成

Apple Developers で発行したKeyを用いて、リクエストパラメータ client_secret に付与するJWTの作成を行います。
JWTとは、 headerpayload から成るJSON構造をエンコードし電子署名をつけたものです。
デコード&エンコードしたJWTの検証は、 https://jwt.io/ がおすすめです。

rubyでのJWTのエンコード&署名の例は以下です。
jwt/ruby-jwt というGemを利用しています。

kidisssubなどは暗号化しておくと良いでしょう。

require 'jwt'
require 'openssl'

  # 署名用の鍵を取得
  def pem
    file = "path/to/key.txt" # Apple Developerで発行したKeyファイル
    # key.txtを読み出し、暗号鍵を生成する
    OpenSSL::PKey::EC.new(File.read(file))
  end

  # 署名付きIDトークンを取得
  def id_token
    header = {
      'kid' => 'Apple Developerで発行したKey ID'
    }
    claim = {
      'iss' => 'Apple DeveloperでのTeam ID',
      'iat' => Time.now.to_i,
      'exp' => Time.now.to_i + 15777000,
      'aud' => 'https://appleid.apple.com',
      'sub' => 'Apple Developerで発行したService ID'
    }
    JWT.encode(
      claim,
      pem,
     'ES256',
      header
    )
  end

レスポンスの検証

レスポンスに含まれるIDトークンの検証には、Appleの公開鍵取得が必要です。
エンドポイントについては Fetch Apple's public key for verifying token signature に記載があります。

jwt/ruby-jwt を用いた検証の実装例です。

  # IDトークンをデコードし署名を検証する
  # public_keysに渡すのは、https://appleid.apple.com/auth/keys の "keys"の値です。
  def decode(id_token, public_keys)
    JWT.decode(
      id_token,
      nil,
      true,
      { algorithms: 'E256', jwks: { keys: public_keys } }
    )
  end

IDなどのユーザー情報

subの値がユーザーの一意の識別子(ユーザーID)です。
そのほかのレスポンスのIDトークンに含まれる情報については Retrieve the User’s Information from Apple ID Servers
に記載があります。
あとは取得したデータをもとに自サービスでのユーザー認証を完了させましょう。

おしまい

書くと簡単ですが実際はたいへんだったのでiOS boys and girlsの皆様はできる限りネイティブでやりましょう:tada:

Thanks to

https://qiita.com/kiwi26/items/5b8cc53ed8d10a403f00
https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple

以上です???
ありがとうございました?

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

Xcode archive時にimport不可のエラー

はじめに

FlutterでAPL開発を行い、APPLEの申請を行っていたのですが、思わぬところでつまずいたので、備忘録として保存しておきます。
 
通常のビルドは、実施可能ですし、シュミレーターの起動や実機での起動も上手く行ってたにも関わらず、appleでの申請用にarchiveしようとしたら内部で使用しているライブラリをimportできないとエラーになりました。

この記事を参考にarchiveしており、エラーになりました。この記事は何も悪くはなく、素晴らしい記事です。

試した内容

  • クリーンして再ビルド。 → NG
  • これを参考に、Podfileを削除して、pod installからやり直し。 →NG
  • これを参考に、deployment target を変更してみる。 →NG
  • これを参考に、読み込みのパスを変更。 →NG

結局解消した方法

これを参考にしました。

Runner.xcproject ではなくRunner.xcworkspaceでXcodeを開いてarchiveする必要があったみたいです。
下記のコマンドで開けるので、お試しください。
open Runner.xcworkspace

結局の原因

これ にこの2つの開き方の違いが記載されているのですが、複数プロジェクトの依存関係を解決できていなかったみたいです。

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

iOS Safariでselect optionにdisplay:noneが効かない対策

表題のとおり、iOSの場合はselect optionにdisplay:noneは効かない。
ググってるとwrapするといいとのことで、たしかにwrapすると何故か消える。
(wrapするタグにはstyleを一切設定していないのに・・!?)
ただ、個々にちまちまwrapさせるのもめんどくさいので
optionにdisplay:noneをしたら勝手にoptionタグをwrapしてくれれば
今まで通りだしいいやんって思った次第。

attrchange.js
https://github.com/meetselva/attrchange/
このjQueryプラグインを利用すると
styleに変化があるとEventが上がってくるので利用させてもらったのが以下のコード。

もう少しやり方あるだろ?って思われそうだけど、ひとまずそこは・・

<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.1/jquery.min.js"></script>
<script src="./Scripts/attrchange.js"></script>

<script>
$(document).ready(function(){

    //iOSのときselect optionをdisplay=noneにしても消えないので、
    //attrchange.jsを利用してcssがdisplay=noneとかにいじられたらoptionタグをwrapする
    $("option").attrchange({
        trackValues: true,
        callback: function(evnt) {
            //console.log(evnt);
            if(evnt.type == "attributes" && evnt.attributeName=="style"){
                if($(this).css("display")=="none"){
                    if($(this).parent().attr("mode")!="wrap"){
                        $(this).wrap("<p mode='wrap'>");
                    }
                }else{  // 空白やdisplay iosだとinlineになるっぽい
if($(this).parent().attr("mode")=="wrap"){
                        $(this).unwrap();
                    }
                }
            }
        }
    });
});
</script>

しかし、なんでoptionをpタグでwrapすると消えるんだろう??

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

flutter_blueがiOSでヘッダーファイルを見つけられなくてbuildできない問題

エラーメッセージ

fatal error: could not build module 'flutter_blue'

あるいは

'FlutterBluePlugin.h' file not found

というエラーが出てビルドできない。

環境

  • Flutter 1.14.6
  • flutter_blue 0.6.3+1
  • cocoapods 1.8.4
  • Xcode 11.3.1

対策

Flutter Pluginsにあるflutter_blue-0.6.3+1/ios/flutter_blue.podspecファイルの一部を下記のように編集する
(Android Studioで作業しているとプロジェクト外のファイルを触ることで警告が出るかもです。)

  ~ 略 ~

  s.subspec 'Protos' do |ss|
    ss.source_files = 'gen/**/*.pbobjc.{h,m}'
    # ss.header_mappings_dir = '.'  <- コメントアウト
    ss.requires_arc = false
    ss.dependency 'Protobuf'
  end

  ~ 略 ~

iOSのプロジェクトディレクトリにあるPodsやPodfile.lockを削除し、インストールをやり直す。

rm -fr Pods Podfile.lock
pod install

参考

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

初めてiOSアプリにとりかかるデザイナーさんへ、私(エンジニア)が楽するためのお願い

はじめに

今回はiOSアプリの開発で、私(エンジニア)がデザインをコードに落とし込む際、どういう解釈をするのかを知ってもらいよりスムーズな開発が行えるようにしたいという目的です。
私がいろいろなプロジェクトでいろいろなデザイナーさんとやりとりしながら感じた、勘所、かゆい所を共有したいと思います。
ここでは、いわゆるiOS HIG(ヒューマンインターフェースガイドライン)を踏まえた上で現場でよく使う用語、役にたった作り方やルールを記載しますので、HIGから学びたい方は、まとめやAppleの公式ページを参照することをおすすめします。
また、文字読むのが苦手な人は、iOSの純正アプリ(AppStoreだけ少々奇抜な気がしますが。)をじっくり見てみることをおすすめします。

iOS Human Interface Guidelines
https://developer.apple.com/design/human-interface-guidelines/ios/overview/themes/

お願いしたいこと

よく使うiOSのレイアウト用語を知ってください

ローンチスクリーン、起動画面、スプラッシュスクリーン LaunchScreen, SplashScreen

brandedsplashscreen.jpeg
ローンチスクリーン(起動画面)とスプラッシュスクリーンは厳密には違うのですが、混同して使われる現場がしばしばあり、認識としてはどちらも、アプリを立ち上げた直後の画面、でいいと思います。
ローンチスクリーンは広義な方で、文字通り起動画面で、上の画像全てです。
一方スプラッシュ画面は、左2つ(Youtube, Twitter)のような画面を指すことが多く、起動直後に一瞬静止画面(たまにアニメーション)が立ち上がり、コンテンツの画面に自動で遷移する、といったものです。純正アプリはほぼこちらを使用していないようで、コンテンツの背景がいきなり登場します。
Appleがデフォルトで用意しているのは、静止画面(画像や文字を配置)が全画面にふわっと出てくるもので、Twitterのようにロゴがアニメーションするものは実装が少々複雑になります。

ステータス/エリア、バー Status/Area, Bar

スクリーンショット 2020-02-04 16.22.35.png
端末上で時間や通信情報、バッテリー情報を表示している場所です。
大きく、表示・非表示LightMode・DarkModeをコントロールできます。
アプリケーション全体を通しての設定を一括で行うのが基本ですが、この場合、後述のナビゲーションエリアの色によって自動でステータスエリアの色(light or dark)が決められたりするので、少々実装コストはかかりますがページごとに色をコントロールすることもできます。
色以外のレイアウトを変更することはできません。

ナビゲーション/エリア、バー、タイトル、ボタン、遷移 Navigation/Area, Bar, Title, Button, Transition

NavigationBar_2x.png
基本的にはステータスエリアの下にくっつけて配置され、ページのタイトルや戻るボタン、完了ボタンや検索ボタン等、が置かれている場所です。
スマートフォンのUIは1ページで多くの情報を表示するために画面をスクロールさせますが、このナビゲーションは常に最前面に配置されており、画面のスクロールの影響を受けません。
これにより、スクロールして情報が変わっても、画面として普遍なものを配置することが多いです。(余談ですが、これを壊してきたのがTwitter等のアプリで、スクロールに応じてナビゲーションエリアに配置されたものや、文字が変わっていくというギミックを取り入れました)
iOSが用意するNavigationには、いくつか制約があり、ガイドラインに沿った実装は簡単に行えるのですが、少し凝った実装をすると独自に作ったNavigationを使用することになり、トータルの実装コストが上がります。
これはiOSが用意してくれているNavigationがデフォルトでいろいろな機能を実装してくれている(特に遷移の管理)ので、そちらの機能を自前で実装しないといけなくなるからです。

タブバー/エリア、ボタン TabBar/Area, Button

TabBar_2x.jpeg
最下部にあるボタンが均等に横並びになっている場所です。
デフォルトだと、アイコンと文字が縦並びになったボタンがセットになっており、均等に配置されていて、選択・非選択状態の色も自動で割り当てられます。
背景色、高さ、ボタンサイズ、選択状態の色・画像、タイトルの有無・色・位置・フォント、それぞれ変更可能です。
タブバーのシステム的利点は、タブバーでの画面遷移は他のタブの状態が保持されている、というところにあり、タブを繰り返し切り替えても、タブごとに同じ画面が表示されます。

セーフエリア SafeArea

e5aca39a-f9a2-4ab8-9f45-08fd95fb845c.png
iPhone,iPad端末は上部にノッチがあったり、時間や通信情報を表示していたり、下部にセンターボタンがあったり、HomeIndicatorがあったりします。
そこで、アプリケーションのコンテンツを表示していい領域を、全端末共通でわかりやすく区切った範囲がこのセーフエリアとなります。
端末によってセーフエリアの見た目は違うのですが、例えばシステム上でセーフエリアの左上から座標を指定すると各端末に合わせた位置に配置してくれるようになるわけです。
セーフエリア自体は外すこともできますが、設定されている場合、デフォルトでは前述のステータスエリアやHomeIndicatorが省かれたエリアになっていますが、ナビゲーションバーをおいたり、タブバーをおくと、そこが省かれたエリアに設定されます。
例えば、ナビゲーションバーを半透明にし、裏でスクロールしているアイテムが見えるようにしたい場合、セーフエリアをステータスエリアまで狭める必要があるわけです。

*SafeAreaは言葉や1枚絵だけでは伝えづらいので、Appleやまとめの記事を参照してみてください。
SafeAreaについてまとめ
https://qiita.com/gentlejkov/items/a626263d452939378b07

テーブル/ビュー、セル Table/View, Cell

1eb44f8d-1907-4949-9208-f2fb7f3ffd1b.png
垂直方向スクロールしながら情報を表示することができるレイアウトです。
大きくセクションとセルという単位に別れており、セクションは大項目(図では[A][D]だったり、何も表示されていなかったり)、セルは小項目(図ではセクションとセクションの間の一つずつ)という認識で問題ないと思います。
テーブルビューはiOSのデフォルトで、かなり多くの便利な機能が実装されており、標準から切り離し独自で実装するのはものすごい実装コストが高いです。

コレクション/ビュー、セル Collection/View, Cell

50390428-f9f2-4cbc-bd99-1cacca4f0617.png
テーブルビューより自由にセルを配置でき、垂直、水平どちらにもスクロールして情報を表示することができるレイアウトです。
セクションとセルの概念はテーブルビューと同じで、こちらもiOSデフォルトで便利機能が盛りだくさんです。
テーブルビューもですが、不規則な配置であればあるほど実装の難易度があがります。不規則の代表例として、よくPinterestのTOPページのUIがあげられます。
根本的にテーブルビューより実装コストは高いです。

モーダル(遷移、 ビュー) Modal(Transition, View)

Modality_2x.jpeg
前の画面の上に覆いかぶさり、前の画面にユーザーが干渉できないビュー(遷移)というイメージです。
iOS13より、画像のようなモーダル表示を見かけるようになりました。今まで全画面のモーダルは、表示上、他の全画面ビューと変わらず、よく見かける特徴としては、左上に←という戻るボタンではなく×という戻るボタンが表示されており、遷移のアニメーションも下からにょきっと出てくるものが多かった印象です。
iOS13も遷移、戻るボタン等はほぼ同じですが、上部の隙間から前画面がのぞいてるように見える表現がデフォルトになりました。もちろん以前のように全画面表示も可能です。

レイアウトの際のパーツ(レイヤー)を意識してください

デフォルト

Action_Sheets_2x.pngActivity_View_2x.pngRefresh_Controls_2x.png
ここまでも度々使用してきたデフォルトという言葉ですが、iOSは実装する際にレイアウトから画像も含めて標準で備えている(デザインを用意する必要がない)ものがあります。
挙げるときりがないですが、ダイアログ、アクションシート、インディケーター等々、iOSの純正アプリで使われていることが多いです。
デフォルトの実装は軽度で、デフォルトから逸脱すればするほど実装が重度になります。
また、デフォルトのUIでも、色、形、位置の変更の難易度がそれぞれ違ったりします。慣れてくると、Appleのこういうニュアンスで使ってほしいという意志を感じ取って、そこからの距離感で実装難易度を予想することができます。
逆に、iOSの純正アプリが実装していることは、やってやれないことはないはずです。(稀に例外あり)

レイアウトのサイズ、座標

レイアウトのサイズ、座標は共に整数(可能であれば2の倍数)です。
スクリーンショット 2020-02-05 11.12.57 2.jpeg
レイアウトは座標をとりやすいように、サイズ、座標ともに配置してください。サイズに関しては透明な余白を設ける方法がよく使用されます。
モノ(オブジェクト)、レイヤーの単位を意識して配置してください。ステータスエリア、ナビゲーションエリア、テーブルビューのセル、左隣のボタン、上のテキスト、X軸の中央、Y軸の中央等々。特に頻出するテーブルビューはセル単位でレイアウトを配置するようにしてください。
2020-02-05 11.12.5.jpeg
1つのアプリケーション内の様々なページに頻出するレイアウトというものが出てくると思いますが、これはデザインでも実装でも同じく再利用する可能性があります。この再利用という単位を意識することが大事です。

ボタンのタップ領域

様々なボタンが出てくると思いますが、ガイドラインにある通り、ボタンのタップ領域は最低44x44ptじゃないといけません。
これは実際やってみるとわかりますが、指でストレスなく押せる最低限のサイズです。
また、ボタンが画像である場合、画像の切り出し方ですが、可能な限りタップ領域を考慮した切り出しをしていただけると助かります。
よく使われる手法は、表示しない部分は透明な状態で画像に組み込む方法です。

画像等リソースの管理を意識してください

命名規則

・フォルダ名、ファイル名は、以下2種類のみ使用します。

  • 半角英数字
  • _(アンダースコア)

・新しく画像を作成する際に命名に困ったら、私(エンジニア)に相談してください。

・命名の法則を決めて、共有してください。
参考:よく使っている画像の命名規則 -> ファイル名のルール
https://qiita.com/manabuyasuda/items/675586be79c4a8eebbfc

共有管理

・ツールによって画像のバージョン管理(変更履歴の管理)ができているなら問題ないが、画像の変更の伝達ミスを避けるため、日付(時間)のフォルダを作り、変更したものを入れて共有するとやりやすいです。

・画像のサイズは基本的に変更しないようにし、どうしても変更する場合は、私(エンジニア)に漏れなく伝えるようにしてください。

・例えばページの名前(ホーム画面、検索画面等)、パーツの名前等のモノの呼称はチームで共通認識化してください。

・画像の名前は変更しないでください。

・不要になった画像も管理してください(可能であれば認識し、私(エンジニア)に伝達してください)。

おわりに

今回挙げたいくつかの事柄には、デザインの幅を狭めるものがあると思います。
なので、あくまで私(エンジニア)が楽する、もとい開発速度を上げるための手法というのを念頭においてください。時間をかけても実現したいデザインがある場合には、これにとらわれる必要はないと思いますし、挑戦的なデザインは時間に余裕があれば大好物です。
この記事で全てを網羅しているわけではないので開発途中で必要に応じて認識合わせを行なっていくことが大事だと思います。
その際のベースの考え方となれば幸いです。

できあがったデザインを見て、レイアウトを把握して、そこに込められた意志を感じながら実装するのが好きです!

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

[Swift5]ニコニコ動画やLINEにあるようなスクロールによって閉じたり開いたりするヘッダー

はじめに

TableViewのスライドに同期して出てきたり閉じたりするヘッダーみたいなバーは、主に検索やビューの切り替えなどに使われ非常に便利なツールです。

簡単に実装できないかなぁとネットを探しまわったんですが、思い通りの情報が得られず結局自分で実装したので共有します。

少し複雑な内容になっていますが、初心者の人にも出来るだけ分かりやすく解説したいと思います。

意外と重宝しそうなツールほどネットに転がっていなかったりしますよね。
この記事が皆さんの助けになれば幸いです。

!初投稿かつSwift初心者なので至らない点があるかもしれません。
(この記事を気に入った人は"いいね"して頂けるとモチベーション上がります)

目次

  • 環境
  • 実行例
  • 考え方
    • 必要な値を取得
    • 適切なヘッダーの状態を判断する
    • ヘッダーの状態を元にヘッダーに対してスクロール処理をする
    • 必要な値を更新する
  • ソースコード
  • コードを紐解く
  • おわりに

環境

  • Xcode 11.2.1
  • Swift5

実行例

testheader.gif

考え方

はじめにTableViewのスクロールに連動して動くツールバーみたいなものをこの記事ではヘッダーとして呼びます。

基本的な考え方としては、TableViewにおけるスクロールの向きに基づいてヘッダーの座標を動かすことになります。

実際に行われる処理は以下の表のような流れになります。

順番 処理内容
1 必要な値を取得する
2 1で得た値をもとに適切なヘッダーの状態を判断する
3 2で得たヘッダーの状態をもとにヘッダーに対してスクロール処理を実行する
4 必要な値を更新する

この流れに沿って説明したいと思います。

また、今回説明するビュー(初期状態)を示した図を下に載せておきます。
ヘッダーはヘッダービューの一部であることが分かりますね。
余談ですが、ヘッダーの上に余白を大きく持たせているのはローディングの際にアニメーションを見せるためです。
(要望があればこちらも記事にします。)

ScrollViewの上の余白をヘッダーの高さ分設けていることに注意してください。

ThirdDiagram (11).png

1.必要な値を取得する

以下が実際に呼び出されるイベント関数となっています。
ここで私たちがこれから定義していく関数を呼び出すことになります。

func scrollViewDidScroll(_ scrollView: UIScrollView) {
        var subSet: [String:CGFloat] = ["up": 0, "down": 0]
        // MARK: -- 以下のinitialHeaderFrameに適切な値を入力するだけで、このプログラムは動作します。
        let initialHeaderFrame: [String:CGFloat] = ["Y": -260, "height": 60]
        let headerFrame: [String:CGFloat] = ["minY": initialHeaderFrame["Y"]! , "maxY": initialHeaderFrame["Y"]! + initialHeaderFrame["height"]!, "height": initialHeaderFrame["height"]!]

        subSet["up"]! += (lastContentOffset - scrollView.contentOffset.y)*0.1
        subSet["down"]! += (lastContentOffset - scrollView.contentOffset.y)*0.1

        let status = self.getHeaderViewStatus(&subSet, scrollView.contentOffset.y, myHeaderView.frame, lastContentOffset, headerFrame)
        self.scrolling(status: status)
        lastContentOffset = scrollView.contentOffset.y

    }

必要な値について述べたいと思います。

  • スクロールビューにおける現在のy座標 (scrollView.contentOffset.y)
  • スクロールビューにおける過去(更新前)のy座標 (lastContentOffset)
  • ヘッダービューのx, y, width, height情報 (myHeaderView.frame)
  • 過去と現在のスクロールビューにおけるy座標の差(スクロール量) (subset:[String:CGFloat])
  • ヘッダービューの初期座標と高さ (initialHeaderFrame: [String:CGFloat])

注意点としては、Swiftは左上が原点となっている座標系であることです。
コード上では過去と現在のスクロールビューにおけるy座標の差
(lastContentOffset - scrollView.contentOffset.y)
のように計算されます。
特筆すべき点としては、subset[String:CGFloat]には同じ値を"up""down"をキーにして二つ保存しています。
これは、上下のスクロールごとに値を用意するためです(理由は後述)。
少し冗長に感じますが便利です。

また、よく使う値としてヘッダービューのデフォルトy座標をヘッダーの高さ分下げたy座標があります。
そちらも、この関数内であらかじめ用意しておくことになります。
(コード内のheaderFrame["maxY"]のことです)

2.得た値をもとに適切なヘッダーの状態を判断する

まずヘッダーの状態について説明します。
今回はプログラムを分かりやすくするために5つの状態を用意しました。

状態 説明
start 初期状態、ビューのトップにヘッダーが存在している状態
move_up 下向きにスクロールしていて、ヘッダーが全て見えきっていない状態
stop_up 下向きにスクロールしていて、ヘッダーが全て見えきっている状態
move_down 上向きにスクロールしていて、ヘッダーが全て隠れきっていない状態
stop_down 上向きにスクロールしていて、ヘッダーが全て隠れきっている状態

これらは全て(関数)列挙型で表現されています。

enum headerViewStatus {
        case start(_ headerViewFrame: CGRect, _ initHeaderFrameMinY: CGFloat)
        case move_up(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect)
        case stop_up(_ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ initHeaderFrameMaxY: CGFloat)
        case move_down(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect)
        case stop_down(_ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ initHeaderFrameMinY: CGFloat)
}

さて、次にヘッダーの状態を判断する関数(getHeaderViewStatus)を見ていきましょう。
上で述べたとおり5つの状態を判断する式を持っています。

5つの状態を判断する条件式をA, B, C, Dとおくと以下のフローチャートのように整理できます。

条件式 簡易的な意味
A 初期状態かどうか
B 下向きにスクロールをしたかどうか
C ヘッダーが全て表示される位置に移動しているかどうか
D ヘッダーを全て隠れる位置に移動しているかどうか

Untitled Diagram (1).png

以下はヘッダーの状態を判断する関数のコードです。

    private func getHeaderViewStatus(_ sub: inout[String:CGFloat], _ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ lastScrollViewY: CGFloat, _ initHeaderFrame: [String:CGFloat]) -> headerViewStatus {
        if (scrollViewY <= (0 - initHeaderFrame["height"]!)) {
            return headerViewStatus.start(headerViewFrame, initHeaderFrame["minY"]!)
        } else if (lastScrollViewY > scrollViewY) {
            sub["down"] = 0
            if(headerViewFrame.origin.y >= scrollViewY + initHeaderFrame["maxY"]!) {
                return headerViewStatus.stop_up(scrollViewY, headerViewFrame, initHeaderFrame["maxY"]!)}
            else { return headerViewStatus.move_up(sub["up"]!, scrollViewY, headerViewFrame)}
        } else {
            sub["up"] = 0
            if(headerViewFrame.origin.y <= scrollViewY + initHeaderFrame["minY"]!) { return headerViewStatus.stop_down(scrollViewY, headerViewFrame, initHeaderFrame["minY"]!)}
            else { return headerViewStatus.move_down(sub["down"]!, scrollViewY, headerViewFrame)}
        }
    }

条件Aは以下のコードで表現されています。

if (scrollViewY <= (0 - initHeaderFrame["height"]!)) 

スクロールビューのy座標が、ヘッダーの高さ分だけ小さい位置に配置されているかについての条件式です。
初期状態ではスクロールビューはヘッダーの高さ分だけ上に配置されていますよね。
なので初期状態、もしくはそれより上に画面を進めた(下向きへスクロールした)場合ではTrueとなり、
それ以外ではFalseとなり次の条件式に進んでいきます。

参考にビューの初期状態を再掲します。

ThirdDiagram (11).png


条件Bは以下のコードで表現されています。

if(lastScrollViewY > scrollViewY)

前回スクロールした際のスクロールビューのy座標の方が現在のy座標よりも下にあるか(大きいか)どうかについての条件式ですね。
この条件式がTrueになるのは、ビューを下にスクロールして画面を上に進めた場合でしょう。

つまりこの条件式はビューを下にスクロールしたかどうかについての条件式となります。

例として上にスクロールした場合のビューの様子を以下の図で示しました。

Untitled Diagram (7).png


条件Cは以下のコードで表現されています。

if(headerViewFrame.origin.y >= scrollViewY + initHeaderFrame["maxY"]!)

ビューを下にスクロールした場合この条件Cを実行することになります。

ビューを下にスクロールした場合はヘッダーを出す必要があります。
しかし、出し過ぎる(ヘッダーを下げ過ぎる)のも問題ですよね。
画面の一番上にくっついているように表示したいです。

条件Cは画面の一番上にくっついているかどうかについて判断します。
画面のトップの位置、それより下にあればTrueで、
まだ完全に表示されきっていないならFalseとなります。

以下の図は条件式がTrue(等しくなる)場合のビューの状態を示しています。

Untitled Diagram (8).png

まずは変数の説明をしましょう。
headerViewFrame.origin.yヘッダービューの現在の座標を示しています。
scrollViewYスクロールビューの現在の座標を示しています。
initHeaderFrame["maxY"]ヘッダービューのデフォルト座標をヘッダーの高さ分下にずらした座標を示しています。

ここで、図における
header_height30
HeaderView_height230
画面の座標を(0, 100)とすると

赤点の座標(initHeaderFrame["maxY"])=(0, -200)
青点の座標(scrollViewY)=(0, 100)
橙点の座標(headerViewFrame.origin.y)=(0, -100)
となります。

そして条件式は、-100 >= 100 + (-200)よりTrueとなります。

これは、(青点+赤点)のy座標橙点のy座標同じ位置、もしくはそれ以上ならばヘッダーは全て表示されていることが保証されていることに由来します。
(皆さんも頭の中で青点を-200分動かしてみてください)

以下の図は上記の考え方を分かりやすく示したものです。(左の状態でTrue、右の状態でFalse)

Untitled Diagram (12).png

青点が二つありますが、上の方にある青点は-200された位置にあるものです。(仮想青点と呼びましょうか)
画面をスクロールしてヘッダーを仕舞う(図の右の状態に遷移)と橙点が移動したのが分かりますね。

この橙点仮想青点よりも上にあるとFalseで、橙点が仮想青点と重なる(もしくは下に位置する)Trueとなります。

この式によって、ヘッダーが画面に全て表示されているかどうか判断できるのです。

では最後に条件Dについても見ていきましょう。ロジックは全く同じです。


条件Dは以下のコードで表現されています。

if(headerViewFrame.origin.y <= scrollViewY + initHeaderFrame["minY"]!)

こちらも先ほど説明した条件Cにとても似ていますね。

不等号が逆になったのと、スクロールビューのy座標に足す値がヘッダービューのデフォルトy座標に変わりました。
(条件式が等しい時にTrueになるよう調節するためです)

こちらもビューがどのような状態の時に条件式が等しくなるのか示しておきましょう。
左の図はTrueの場合で、右の図はFalseの場合です。

それぞれの点は、
赤点の座標(initHeaderFrame["minY"])
青点の座標(scrollViewY)
橙点の座標(headerViewFrame.origin.y)
を示しています。

Untitled Diagram (13).png

この橙点仮想青点よりも下にあるとFalseで、橙点が仮想青点と重なる(もしくは上に位置する)Trueとなります。

つまり、ヘッダーが全て画面の上に隠れたかについての条件式です。


以上で全ての適切なヘッダーの状態を判断する条件式の説明を終えました。
こうして見ると、意外と複雑に考える必要があったんだという事が分かりますね。

さて、次はヘッダーの状態において適切な処理をしていくことになります。

3.ヘッダーの状態をもとにヘッダーに対してスクロール処理を実行する

まずは、ヘッダーの状態と、その際の適切なスクロール処理について表にまとめました。

状態 適切な処理
start 画面の一番上にくっついているように表示する
move_up ヘッダーを下げて表示する
stop_up ヘッダーを画面の一番上にくっついているように固定する
move_down ヘッダーを上げて画面から退場させる
stop_down ヘッダーが画面から見えないギリギリの位置に固定する

つまりヘッダーは以下の図の矢印の間を行ったり来たりするように動きます。

ThirdDiagram (12).png

スクロールについての処理を行うので、y座標に対する操作だけに注目してください。

注意点としては、ヘッダーではなくヘッダービューに対しての操作を行なっているという事です。

ヘッダーはヘッダービューの一部ですからね。


状態がstartの場合は処理は以下のように記述されています。

func start(_ headerViewFrame: CGRect, _ initHeaderFrameMaxY: CGFloat) {
            print("Start")
            myHeaderView.frame 
            = CGRect(x: headerViewFrame.origin.x, 
                     y: initHeaderFrameMaxY, 
                     width: headerViewFrame.width, 
                     height:  headerViewFrame.height)
}

initHeaderFrame_maxYはヘッダーのデフォルトy座標のことなので、ヘッダービューには初期状態がセットされます。


状態がmove_upの場合は処理は以下のように記述されています。

func move_up(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect) {
            print("Move_up")
            myHeaderView.frame 
            = CGRect(x: headerViewFrame.origin.x,  
                     y: headerViewFrame.origin.y + sub, 
                     width: headerViewFrame.width, 
                     height: headerViewFrame.height)
}

subという変数が出てきましたね。これは スクロール量です。
これは1.必要な値を取得するで述べた通り、subSetのキー"up"の値で、以下の計算式で求められます。

(lastContentOffset - scrollView.contentOffset.y)

上向きにスクロールしているので、lastContentOffset > scrollView.contentOffset.yです。
したがって、subの値はになることが分かりますね。

また、headerViewFrame.origin.yは現在のヘッダービューにおけるy座標なので、
(headerViewFrame.origin.y + sub)
は現在のヘッダービューにおけるy座標をスクロール量分大きくしている(下げている)ことが分かります。
ThirdDiagram (13).png


状態がstop_upの場合は処理は以下のように記述されています。

func stop_up(_ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ initHeaderFrameMaxY: CGFloat) {
            print("Stop_up")
            myHeaderView.frame 
            = CGRect(x: headerViewFrame.origin.x, 
                     y: (scrollViewY + initHeaderFrameMaxY), 
                     width:  headerViewFrame.width, 
                     height:  headerViewFrame.height)
}

これはスクロールビューのy座標(scrollViewY)に常にヘッダーのデフォルトy座標+ヘッダーの高さ(initHeaderFrameMaxY)を足していますね。

条件Cの処理を思い出してください。ロジックは全く同じです。
以下の図に青点が二つありますが、上の方が(scrollViewY)+(initHeaderFrameMaxY)を表している仮想青点です。

仮想青点にヘッダービューのy座標を合わせる処理ということですね。

また、この処理が実行されるのは橙点と青点が重なる場合なので、常に図の左側を表示し続けることになります。
つまりヘッダーを常に表示されるように固定しているという事なんですね。
Untitled Diagram (12).png


状態がmove_downの場合は処理は以下のように記述されています。

func move_down(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect) {
            print("Move_down")
            myHeaderView.frame 
            = CGRect(x: headerViewFrame.origin.x, 
                     y: headerViewFrame.origin.y + sub, 
                     width: headerViewFrame.width, 
                     height: headerViewFrame.height)
}

こちらもmove_upの場合の処理と同じです。
違いはsubが負の値になっている事ですね。スクロールの向きに由来するものです。


状態がstop_downの場合は処理は以下のように記述されています。

func stop_down(_ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ initHeaderFrameMinY: CGFloat) {
            print("Stop_down")
            myHeaderView.frame 
            = CGRect(x: headerViewFrame.origin.x, 
                     y: (scrollViewY + initHeaderFrameMinY),
                     width: headerViewFrame.width, 
                     height: headerViewFrame.height)
}

こちらもstop_upの場合の処理と同じように、この処理にたどり着くまでの条件をみていくと分かります。

stop_downの場合は条件Dを参照してください。

この処理はヘッダーを常に見えないギリギリの位置に固定していることになります。


必要な値を更新する

最後はプログラムに必要な値を更新する処理です。

と言っても、明示的に行うのは以下のたった一行のコードです。

lastContentOffset = scrollView.contentOffset.y

現在のスクロールビューのy座標を次のサイクルで使えるように、更新しているだけですね。

さて、ここまでで論理的なプログラムの説明を終わります。
お疲れ様でした。

ソースコード

とりあえず、ポイントとなるプログラムは以下に載せておきます。
Githubにサンプルを上げておくので、そちらも参考にされてみてください。

https://github.com/Hajime-Ito/HeaderTestSwift

 var lastContentOffset: CGFloat = 0

 enum headerViewStatus {
        case start(_ headerViewFrame: CGRect, _ initHeaderFrameMinY: CGFloat)
        case move_up(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect)
        case stop_up(_ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ initHeaderFrameMaxY: CGFloat)
        case move_down(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect)
        case stop_down(_ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ initHeaderFrameMinY: CGFloat)
    }

    private func getHeaderViewStatus(_ sub: inout[String:CGFloat], _ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ lastScrollViewY: CGFloat, _ initHeaderFrame: [String:CGFloat]) -> headerViewStatus {
        if (scrollViewY <= (0 - initHeaderFrame["height"]!)) {
            return headerViewStatus.start(headerViewFrame, initHeaderFrame["minY"]!)
        } else if (lastScrollViewY > scrollViewY) {
            sub["down"] = 0
            if(headerViewFrame.origin.y >= scrollViewY + initHeaderFrame["maxY"]!) {
                return headerViewStatus.stop_up(scrollViewY, headerViewFrame, initHeaderFrame["maxY"]!)}
            else { return headerViewStatus.move_up(sub["up"]!, scrollViewY, headerViewFrame)}
        } else {
            sub["up"] = 0
            if(headerViewFrame.origin.y <= scrollViewY + initHeaderFrame["minY"]!) { return headerViewStatus.stop_down(scrollViewY, headerViewFrame, initHeaderFrame["minY"]!)}
            else { return headerViewStatus.move_down(sub["down"]!, scrollViewY, headerViewFrame)}
        }
    }


    private func scrolling(status: headerViewStatus) {

        func start(_ headerViewFrame: CGRect, _ initHeaderFrameMaxY: CGFloat) {
            print("Start")
            myHeaderView.frame = CGRect(x: headerViewFrame.origin.x, y: initHeaderFrameMaxY, width: headerViewFrame.width, height:  headerViewFrame.height)
        }

        func move_up(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect) {
            print("Move_up")
            myHeaderView.frame = CGRect(x: headerViewFrame.origin.x, y: headerViewFrame.origin.y + sub, width: headerViewFrame.width, height: headerViewFrame.height)
        }

        func stop_up(_ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ initHeaderFrameMaxY: CGFloat) {
            print("Stop_up")
            myHeaderView.frame = CGRect(x: headerViewFrame.origin.x, y: (scrollViewY + initHeaderFrameMaxY), width:  headerViewFrame.width, height:  headerViewFrame.height)
        }

        func move_down(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect) {
            print("Move_down")
            myHeaderView.frame = CGRect(x: headerViewFrame.origin.x, y: headerViewFrame.origin.y + sub, width: headerViewFrame.width, height: headerViewFrame.height)
        }

        func stop_down(_ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ initHeaderFrameMinY: CGFloat) {
            print("Stop_down")
            myHeaderView.frame = CGRect(x: headerViewFrame.origin.x, y: (scrollViewY + initHeaderFrameMinY), width: headerViewFrame.width, height: headerViewFrame.height)
        }

        switch status {
        case let .start(headerViewFrame, initHeaderFrameMinY): start(headerViewFrame, initHeaderFrameMinY)
        case let .move_up(sub, scrollViewY, headerViewFrame): move_up(sub, scrollViewY, headerViewFrame)
        case let .stop_up(scrollViewY, headerViewFrame, initHeaderFrameMaxY): stop_up(scrollViewY, headerViewFrame, initHeaderFrameMaxY)
        case let .move_down(sub, scrollViewY, headerViewFrame): move_down(sub, scrollViewY, headerViewFrame)
        case let .stop_down(scrollViewY, headerViewFrame, initHeaderFrameMinY): stop_down(scrollViewY, headerViewFrame, initHeaderFrameMinY)
        }
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        var subSet: [String:CGFloat] = ["up": 0, "down": 0]
        // MARK: -- 以下のinitialHeaderFrameに適切な値を入力するだけで、このプログラムは動作します。Yにはヘッダービューのy座標を、heightにはヘッダーの高さを入力してください。
        let initialHeaderFrame: [String:CGFloat] = ["Y": -260, "height": 60]
        let headerFrame: [String:CGFloat] = ["minY": initialHeaderFrame["Y"]! , "maxY": initialHeaderFrame["Y"]! + initialHeaderFrame["height"]!, "height": initialHeaderFrame["height"]!]

        subSet["up"]! += (lastContentOffset - scrollView.contentOffset.y)*0.1
        subSet["down"]! += (lastContentOffset - scrollView.contentOffset.y)*0.1

        let status = self.getHeaderViewStatus(&subSet, scrollView.contentOffset.y, myHeaderView.frame, lastContentOffset, headerFrame)
        self.scrolling(status: status)
        lastContentOffset = scrollView.contentOffset.y

    }

 コードを紐解く

ではコードを紐解いていきましょう。
全部説明する必要もないと思うので、特筆すべき部分だけにしときます。


1つ目は参照渡しですね。
getHeaderViewStatusの引数に注目してください。

private func getHeaderViewStatus(_ sub: inout[String:CGFloat], ....)

引数subinoutになっていますね。
これは参照渡しを明示的に行なっているという意味です。

通常、Swiftでは関数に渡す引数は値渡しとなります。

しかし、この関数ではsubに対する操作も行いたいので、参照渡しをすることになります。


2つ目はsub[String:CGFloat]についてです。
これは先ほどの参照渡しの話と関係しています。

subはスクロール量をもつ辞書型の変数でしたね。
それぞれ、updownをキーとして同じ値を持っていました。

これはそれぞれ上下にスクロールを始めてからのスクロール量を計算するためです。

そのために、getHeaderViewStatus関数内において、
上にスクロールを始めたら、sub["down"]を初期化してsub["up"]の値を利用する
下にスクロールを始めたら、sub["up"]を初期化してsub["down"]の値を利用する
ようなプログラムになっているんですね。

そして初期化処理を行うために参照渡しにしていた訳ですね。


以上で特筆すべきコードの説明を終わります。

おわりに

以上で終わりとなります。

理解するのに以外と頭をひねる必要があったかも知れませんね。
スクロール方向と画面の進む向きが逆になっていたりするからでしょうか?

また機会があれば、他に役に立ちそうなことも記事にしていきたいと思います。

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

【iOS】書類スキャンライブラリ「WeScan」の紹介と日本語化など

WeScanとは

WeScan makes it easy to add scanning functionalities to your iOS app! It's modelled after UIImagePickerController, which makes it a breeze to use.
https://github.com/WeTransfer/WeScan
wescan2.gif

このように、カメラやローカルの画像を取り込んで書類っぽい部分をcropできるライブラリ。例えばEvernote Scannableの撮影機能に近い。

2020年に入っても活発に開発されており、いまは紹介映像よりもう少しリッチになっている。

使い方

インストール

Podfileにpod 'WeScan'を追加してpod install

カメラを起動してスキャン

ImageScannerControllerのインスタンスを作ってDelegateを設定、presentすると紹介映像のような画面が出てくる。

import UIKit
import WeScan // ←忘れずに
class MyViewController: UIViewController {
    @IBOutlet weak var imageView: UIImageView!
    func startCamera() {
        let scannerViewController = ImageScannerController()
        scannerViewController.imageScannerDelegate = self
        present(scannerViewController, animated: true)
    }
}

ImageScannerControllerDelegateを使ってスキャンの結果を取得できる。

extension MyViewController: ImageScannerControllerDelegate {
    func imageScannerController(_ scanner: ImageScannerController, didFailWithError error: Error) {
        print(error)
    }
    func imageScannerController(_ scanner: ImageScannerController, didFinishScanningWithResults results: ImageScannerResults) {
        let croppedImage = results.croppedScan.image // UIImage
        imageView.image = croppedImage
        scanner.dismiss(animated: true)
    }
    func imageScannerControllerDidCancel(_ scanner: ImageScannerController) {
        scanner.dismiss(animated: true)
    }
}

ライブラリ等から画像を渡してクロッピング

インスタンス作成時に画像を渡すだけ

ImageScannerControllerのインスタンス作成時に画像を渡すと、カメラは起動せずに渡した画像の編集画面となる。

let scannerViewController = ImageScannerController(image: image, delegate: self) // どこかから画像を渡す。ついでにdelegateも設定
present(scannerViewController, animated: true)

すっごい使いやすい…
image.png

iOS標準のUIImagePickerControllerで画像を持ってきて渡す例

class MyViewController: UIViewController {
    func openLibrary() {
        let imagePicker = UIImagePickerController()
        imagePicker.delegate = self
        imagePicker.sourceType = .photoLibrary
        present(imagePicker, animated: true)
    }
}
extension MyViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        picker.dismiss(animated: true)
    }

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
        picker.dismiss(animated: true)

        guard let image = info[.originalImage] as? UIImage else { return }
        let scannerViewController = ImageScannerController(image: image, delegate: self) //❗️選んだ画像をWeScanに渡す
        present(scannerViewController, animated: true)
    }
}

extension MyViewController: ImageScannerControllerDelegate { //❗️ここは前と同じ
    func imageScannerController(_ scanner: ImageScannerController, didFailWithError error: Error) {
        print(error)
    }
    func imageScannerController(_ scanner: ImageScannerController, didFinishScanningWithResults results: ImageScannerResults) {
        let croppedImage = results.croppedScan.image
        imageView.image = croppedImage
        scanner.dismiss(animated: true)
    }
    func imageScannerControllerDidCancel(_ scanner: ImageScannerController) {
        scanner.dismiss(animated: true)
    }
}

日本語化とカスタマイズ

すごく良いライブラリなのだが、ちょっと変更したいところもあって…

日本語がない。
image.png

また、今回の用途だとクロップ設定の初期範囲が少し狭いのが気になった。
image.png

Fork

なので、Forkしてカスタマイズします。
image.png

ForkしたリポジトリをPodfileに登録して使う方法は以下に単体で記事を作りました。
【CocoaPods】iOSのライブラリをForkして使う

ちなみにWeScanはMITライセンス。
image.png

日本語化

ForkしたWeScanをXcodeで開く。
image.png
↓選択
image.png
↓選択
image.png
↓+を押す
image.png
↓選択
image.png
↓チェック
image.png
↓選択
image.png
WeScan/Localizable.strings以下にJapaneseが追加されるので、例えば以下のように編集。

"wescan.edit.button.next" = "次へ";
"wescan.edit.title" = "切り取り";

"wescan.review.title" = "プレビュー";

"wescan.scanning.cancel" = "キャンセル";
"wescan.scanning.auto" = "オート";
"wescan.scanning.manual" = "マニュアル";

初期クロップ範囲を変更する

このあたりを

WeScan/Edit/EditScanViewController.swift
    /// Generates a `Quadrilateral` object that's centered and one third of the size of the passed in image.
    private static func defaultQuad(forImage image: UIImage) -> Quadrilateral {
        let topLeft = CGPoint(x: image.size.width / 3.0, y: image.size.height / 3.0)
        let topRight = CGPoint(x: 2.0 * image.size.width / 3.0, y: image.size.height / 3.0)
        let bottomRight = CGPoint(x: 2.0 * image.size.width / 3.0, y: 2.0 * image.size.height / 3.0)
        let bottomLeft = CGPoint(x: image.size.width / 3.0, y: 2.0 * image.size.height / 3.0)

        let quad = Quadrilateral(topLeft: topLeft, topRight: topRight, bottomRight: bottomRight, bottomLeft: bottomLeft)

        return quad
    }

このように変更すると、

        let topLeft = CGPoint(x: 0, y: 0)
        let topRight = CGPoint(x: image.size.width, y: 0)
        let bottomRight = CGPoint(x: image.size.width, y: image.size.height)
        let bottomLeft = CGPoint(x: 0, y: image.size.height)

画像全体が選択された状態から始まる。
image.png

Podfile

コードを書き換えたらpushし、自分のアプリに戻ってPodfileを編集。
repoとbranchを指定してpod installすれば完了。

  pod 'WeScan', :git => 'git@github.com:{{ your name }}/WeScan.git', :branch => '{{ your branch }}'
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む