Qiita:TeamのテンプレートとJSONからテキストを組み立てるヤツ
あけましておめでとうございます。
プログラミング初めとしてnaoty/qiita-buildという簡単なrubygemを作りました。Qiita:Teamで下のようなテンプレートがあったときに
# 自己紹介 * 氏名: %{fullname} * ニックネーム: %{nickname} * 居住地: %{location} * 生年月日: %{birthday}
標準入力でJSON文字列を渡すと、変数の中身をそれで展開するだけ。
$ echo '{"fullname": "Naoto Kaneko", "nickname": "naoty", "location": "Tokyo", "birthday": "1987/6/2"}' | qiita-build -t <TEAM> -a <ACCESS TOKEN> <TEMPLATE ID> # 自己紹介 * 氏名: Naoto Kaneko * ニックネーム: naoty * 居住地: Tokyo * 生年月日: 1987/6/2
とある用途で使いたくて作ったものの、Qiita:Team周りのワークフローを自動化するときに便利そうだなと思ったのでrubygemにしました。
本年も宜しくお願い致します。
activeadmin読んだ
activeadmin/activeadminを初めて使うことになったので、どういう仕組みになっているのか調べてみた。
TL;DR
rails g active_admin:install
を実行するとlib/generators/active_admin/install_generator.rbが実行され、ActiveAdmin.routes(self)
がconfig/routes.rbに追加される。- app/admin/以下にあるResource定義ファイル内で実行される
ActiveAdmin.register
では、ActiveAdmin::Resource
インスタンスが生成され、動的にResourceごとのcontrollerが生成される。それらはすべてActiveAdmin::ResourceController
を継承している。 - config/routes.rbに追加された
ActiveAdmin.routes(self)
は内部でapp/admin/以下のファイルをロードし(このタイミングで上述のActiveAdmin.register
が実行される)、ActiveAdmin::Resource
インスタンスから動的にroutingが定義される。
長いので、以下ActiveAdmin::
をAA::
と略記する。
Generator
Active Adminをセットアップするにはまずrails g active_admin:install
を実行する。
このとき、lib/generators/active_admin/install_generator.rbに定義されたRails::Generators::Base
のサブクラスにあるpublicメソッドが上から順番に実行される。Railsはlib/generators/**/*_generator.rbにマッチするファイルに定義されたRails::Generators::Base
のサブクラスをRails Generatorとして実行することができる。
# lib/generators/active_admin/install_generator.rb module ActiveAdmin module Generators class InstallGenerator < ActiveRecord::Generators::Base # ... def setup_routes if options[:user] inject_into_file "config/routes.rb", "\n ActiveAdmin.routes(self)", after: /devise_for .*, ActiveAdmin::Devise\.config/ else route "ActiveAdmin.routes(self)" end end # ... end end end
いくつかメソッドが定義されている中でsetup_routes
を見ると、config/routes.rbにActiveAdmin.route(self)
を追記しているようだ。self
はRails.application.routes.draw do ... end
のブロック内でのself
なのでActionDispatch::Routing::Mapper
インスタンスを表している。
Register a resource
Generatorでファイルの追加と変更を行ったあとは、管理画面で管理するResourceを作成する。例えば、rails g active_admin:resource Post
を実行すると以下のようなapp/admin/post.rbが生成される。
# app/admin/post.rb ActiveAdmin.register Post do end
このブロックの中にviewやcontrollerの設定を追加していくのだけど、まずActiveAdmin.register
の定義を調べる。
# lib/active_admin.rb module ActiveAdmin class << self # ... def application @application ||= ::ActiveAdmin::Application.new end # ... delegate :register, to: :application # ... end end
ActiveSupportが拡張したメソッドdelegate
によって、ActiveAdmin.register
の処理は実際にはAA::Application#register
が行っている。
# lib/active_admin/application.rb def register(resource, options = {}, &block) ns = options.fetch(:namespace){ default_namespace } namespace(ns).register resource, options, &block end
options[:namespace]
がなければdefault_namespace
つまり:admin
がns
に入る。#namespace
はnamespaces[ns]
があればそれを返し、なければAA::Namespace
インスタンスを初期化しnamespaces
に追加した上で返す。よって、AA::Namespace#register
が処理が渡っている。
# lib/active_admin/namespace.rb def register(resource_class, options = {}, &block) config = find_or_build_resource(resource_class, options) register_resource_controller(config) parse_registration_block(config, resource_class, &block) if block_given? reset_menu! ActiveAdmin::Event.dispatch ActiveAdmin::Resource::RegisterEvent, config config end
find_or_build_resource
はAA::Resource
インスタンスを返す。#register_resource_controller
は以下のように定義されており、Resource
インスタンスから動的にAA::ResourceController
を継承するResourceごとのcontrollerを定義している。
# lib/active_admin/namespace.rb def register_resource_controller eval "class ::#{config.controller_name} < ActiveAdmin::ResourceController; end" config.controller.active_admin_config = config end
parse_registration_block
は上述の例のapp/admin/post.rbでActiveAdmin.register
に渡されていたブロックを評価する部分だと思う。ブロックの中身を独自のDSLとして評価してカスタマイズを行っていると思う。
Routing
Generatorによってconfig/routes.rbに追加されたActiveAdmin.routes
の定義を調べる。
# lib/active_admin.rb module ActiveAdmin # ... def application @application ||= ::ActiveAdmin::Application.new end # ... delegate :routes, to: :application # ... end
delegate
はActiveSupportが拡張しているメソッドで、メソッドの呼び出しをto
で指定したオブジェクトに委譲する。なので、ActiveAdmin.routes
は実際にはAA::Application#routes
を指している。
# lib/active_admin/application.rb def routes(rails_router) load! router.apply(rails_router) end
load!
はapp/admin/**/*.rbをKernel.load
する。このとき上述したapp/admin/post.rbのようなResource定義ファイルがロードされる。そして、ActiveAdmin.register
が実行され各Resourceのcontrollerが定義される。
router
はRouter
インスタンスなので、Router#apply
を調べる。
# lib/active_admin/router.rb def apply(router) define_root_routes router define_resource_routes router end
まずdefine_root_routes
は以下のように定義されている。
# lib/active_admin/router.rb def define_root_routes(router) router.instance_exec @application.namespaces.values do |namespaces| namespaces.each do |namespace| if namespace.root? root namespace.root_to_options.merge(to: namespace.root_to) else namespace namespace.name do root namespace.root_to_options.merge(to: namespace.root_to) end end end end end
このrouter
はActionDispatch::Routing::Mapper
であり、@application.namespaces.values
はAA::Namespace
インスタンスの配列だ。
ActiveAdmin.register
に特にoptionを指定しない場合、namespace.root?
はtrue
となる。namespace.root_to_options
とnamespace.root_to
がどこで定義されているのか不明。。。なんだけど、AA::Application
内にこれらが定義されており、root_to_options
は{}
でroot_to
は"dashboard#index"
となっている。どのようにしてAA::Namespace
にそれらが定義されるのか不明ではあるが、おそらくこれらの値が使われるのだと思う。よって、結局このメソッドはroot to: "dashboard#index"
としているだけだ。
# lib/active_admin/router.rb def define_resource_routes(router) router.instance_exec @application.namespaces, self do |namespaces, aa_router| resources = namespaces.value.flat_map{ |n| n.resources.values } resources.each do |config| routes = aa_router.resource_routes(config) # ... instance_exec &routes end end end
config
は先述したAA::Resource
インスタンスだ。aa_router
はAA::Router
インスタンスなのでAA::Router#resource_routes
を見る。
# lib/active_admin/router.rb def resource_routes(config) Prox.new do build_route = proc{ |verbs, *args| [*verbs].each{ |verb| send verb, *args } } build_action = proc{ |action| build_route.call(action.http_verb, action.name) } case config when ::ActiveAdmin::Resource resources config.resource_name.route_key, only: config.defined_actions do member do config.member_actions.each &build_action end collection do config.collection_actions.each &build_action post :batch_action if config.batch_action_enabled? end end when ::ActiveAdmin::Page # ... else # ... end end end
このメソッドで返されるProcオブジェクトはActionDispatch::Routing::Mapper
インスタンスのコンテキストでinstance_exec
されるため、要するにこのProcオブジェクト内の処理がそのままconfig/routes.rb内のroutingの設定となる。Resourceインスタンスの情報から動的にroutingを組み立てているようだ。
所感
軽く触ってみたけど、Resource定義ファイルに独自DSLでviewを書いていくのが非常にカスタマイズが大変だし覚えることが多そうだな、あまり筋がよくなさそうという印象を受けた。
で、調べてみた結果、Resource定義ファイルから動的にcontrollerとかroutingとかを定義していて、それらをカスタマイズするのに独自DSLを使うという構図になっていることが分かった。管理画面って、ビジネスサイドの要求によってどんどんカスタマイズが必要になるので、カスタマイズに独自のDSLを覚えなくてはいけないとか、場合によってはカスタマイズできないみたいな状況は大きな問題だと思う。だから、動的にいろいろ生成する方針は管理画面の実装には適していないのではないかと思った。なんでこれがこんなに支持されているのかよくわからない。
RailsのReloaderの仕組み
Resqueのworker上で実行されるコードが古いまま更新されないというような問題があって、意味もわからず書いたコードでなんとかその場を収めたんだけど、気持ち悪いのでRailsでいかにしてコードが更新されるのか調べてみることにした。
TL;DR
未定義の定数が参照されると、ActiveSupport::ModuleConstMissing
によって拡張されたModule#const_missing
が呼ばれる。命名規則に基いて定数名からファイル名が推測され、autoload_paths
に存在するファイルを見つける。そのファイルがロード済みであればそこで終了。ロード済みでなければKernel.load
でロードし、ロードされた定数は配列で管理される。
development環境では、Railsのmiddleware stackにActionDispatch::Reloader
というmiddlewareがあり、リクエストごとにファイルの最終更新日時を確認し、変更されていればActiveSupport::Dependencies.clear
を呼ぶ。これによって、ロード済みのファイルが空っぽになり、ロードされた定数もすべて削除される(=未定義状態に戻る)。なので、ファイルが変更されるたびにconst_missing
から始まる一連のフローが起こり、Kernel.load
によって最新のコードがロードされるようになっている。
以下は、上述の結論に至るまでのソースコードリーディングのメモです。分かりにくいかも。
ActionDispatch::Reloader
ActionDispatch::ReloaderはRackミドルウェアなので、#call
の中で初期化時に受け取った他のRackミドルウェアの#call
を呼んでいる。その前後でリロードに関する処理を実行しているはずだ。
def call(env) @validated = @condition.call prepare! response = @app.call(env) response[2] = ::Rack::BodyProxy.new(response[2]) { cleanup! } response rescue Exception cleanup! raise end def prepare! run_callbacks :prepare if validated? end def cleanup! run_callbacks :cleanup if validated? ensure @validated = true end
次のRackミドルウェアが処理を行う前後でprepare!
とcleanup!
を呼んでいる。その中身はrun_callbacks
を呼んでいる。これはActiveSupport::Callbacks
で定義されているメソッドで、set_callbacks
で登録されたcallbackを実行する。なので、:prepare
と:cleanup
というイベントに対してどこかで登録されたcallbackがされている。このcallbackの登録を行うメソッドもActionDispatch::Reloader
に含まれている。
def self.to_prepare(*args, &block) # ... set_callbacks(:prepare, *args, &block) end def self.to_cleanup(*args, &block) # ... set_callbacks(:cleanup, *args, &block) end
この2つのメソッドを使ってcallbackの登録が行われている。これらを呼び出している箇所を探すと、Rails::Application::Finisher
で呼ばれていることがわかる。
Rails::Application::Finisher
initializer :set_clear_dependencies_hook, group: :all do callback = lambda do ActiveSupport::DescendantsTracker.clear ActiveSupport::Dependencies.clear end if config.reload_classes_only_on_change reloader = config.file_watcher.new(*watchable_args, &callback) self.reloaders << reloader ActionDispatch::Reloader.to_prepare(prepend: true) do reloader.execute end else ActionDispatch::Reloader.to_cleanup(&callback) end end
initializer
はRails::Initializable
で定義されているメソッドでRailsの初期化時に実行される処理を登録することができる。つまり、Railsの初期化時に:prepare
または:cleanup
のcallbackを登録しているということになる。
reload_classes_only_on_change
という設定はデフォルトでtrue
になっていて、依存するファイルが変更されたときだけクラスを再読み込みするかどうかを制御する。file_watcher
はデフォルトではActiveSupport::FileUpdateChecker
を指している。つまり、デフォルトでは、:prepare
のときにActiveSupport::FileUpdateChecker#execute
が実行されるように設定されていることになる。
ActiveSupport::FileUpdateChecker
は初期化時に渡されたファイルを配列として受け取り、またそれらが更新されたときに実行されるブロックを受け取る。#execute
はファイルが更新されているかどうかに関わらずブロックを実行する。ここで実行されるのは、以下のブロックとなる。
callback = lambda do ActiveSupport::DescendantsTracker.clear ActiveSupport::Dependencies.clear end
ここまでをまとめると、リクエストを受けるごとに上の2つのメソッドが実行されコードのリロードが行われるということになる。
ActiveSupport::DescendantsTracker
def clear if defined? ActiveSupport::Dependencies @@direct_descendants.each do |klass, descendants| if ActiveSupport::Dependencies.autoloaded?(klass) @@direct_descendants.delete(klass) else descendants.reject! { |v| ActiveSupport::Dependencies.autoloaded?(v) } end end else @@direct_descendants.clear end end
@@direct_descendants
の中身を消去しているようだ。これはHashであり、中身がキーがクラスで、値がそのクラスを継承したサブクラスの配列となっている。Class#inherited
をoverrideしており、ActiveSupport::DescendantsTracker
をextend
しているクラスを継承したタイミングで@@direct_descendants
に追加される。ActiveSupport::DescendantsTracker
は例えばActiveRecord::Base
でextend
されているため、ActiveRecord::Base
のサブクラス、つまり通常のModelクラスはActiveRecord::Base.descendants
から取得できる。これを利用しているのが先述したActiveSupport::Callbacks
で、callbackをサブクラスから親クラスへ辿っていくときに利用されている。
ActiveSupport::Dependencies
def clear log_call loaded.clear loading.clear remove_unloadable_constants! end
loaded
とloading
はクラス変数@@loaded
および@@loading
へのアクセサでmattr_accessor
によって定義されている。そして、これらのクラス変数の実体はSet
オブジェクトだ。
次にloaded
とloading
にいつ何が追加されるのか調べると、ActiveSupport::Dependencies.require_or_load
というメソッドで呼ばれている。
def require_or_load(file_name, const_path = nil) # ... file_name = $` if file_name =~ /\.rb\z/ expanded = File.expanded_path(file_name) return if loaded.include?(expanded) loaded << expanded loading << expanded begin if load? # ... load_args = ["#{file_name}.rb"] load_args << const_path unless const_path.nil? if !warnings_on_first_load or history.include?(expanded) result = load_file(*load_args) else enable_warnings { result = load_file(*load_args) } end else # ... end rescue Exception loaded.delete expanded raise ensure loading.pop end history << expanded result end
loaded
とloading
に追加されているのはおそらくロードするファイルの絶対パスと思われる。そして、一連のロードが完了したらloading
からは削除されるようだ。loading
は読み込み中の再読み込みを防ぐために一時的に利用される変数らしい。一方、loaded
は既にロード済みかどうかをチェックして、ロード済みであればrequire_or_load
を中断させるために使われているようだ。実際のロードの処理はload_file
で行われるようだ。ActiveSupport::Dependencies.clear
によってloaded
が空になると、require_or_load
内で再度load_file
を実行することになる。
def load_file(path, const_paths = loadable_constants_for_path(path)) # ... const_paths = [const_paths].compact unless const_paths.is_a? Array parent_paths = const_paths.collect { |const_path| const_path[/.*(?=::)/] || ::Object } result = nil newly_defined_paths = new_constants_in(*parent_paths) do result = Kernel.load path end autoloaded_constants.concat newly_defined_paths unless load_once_path?(path) autoloaded_constants.uniq! # ... result end
const_paths
はapp/models/user.rb
とapp/controllers/users_controller.rb
いうファイルがあれば"User"
と"UsersController"
という表す文字列を含む配列となる。parent_paths
はconst_paths
の中で"Admin::UsersController"
のようなネストするものと"::Object"
を抽出した配列となる。new_constants_in
は渡したブロックを実行し、その中で新たにロードされた定数を返す。なので、Kernel.load
がとりあえず実行されるようだ。
話を少し戻してrequire_or_load
はどこで呼ばれているかを調べると、load_missing_constant
で呼ばれており、さらにこのメソッドはActiveSupport::ModuleConstMissing#const_missing
で呼ばれている。
module ModuleConstMissing # ... def const_missing(const_name) from_mod = anonymous ? guess_for_anonymous(const_name) : self Dependencies.load_missing_constant(from_mod, const_name) end end
そしてこのmoduleは以下のようにしてModule
クラスにinclude
されるため、デフォルトのconst_missing
の挙動をoverrideすることになる。
module ActiveSupport module Dependencies # ... def hook! Object.class_eval { include Loadable } Module.class_eval { include ModuleConstMissing } Exception.class_eval { include Blamable } end # ... end end ActiveSupport::Dependencies.hook!
つまり、定義されていない定数を参照する→const_missing
→load_missing_constant
→require_or_load
→load_file
という順番で呼ばれることになる。
ここで、ActiveSupport::Dependencies.clear
の定義に戻る。
def clear log_call loaded.clear loading.clear remove_unloadable_constants! end
見ていなかったremove_unloadable_constants!
について見ていく。
def remove_unloadable_constants! autoloaded_constants.each { |const| remove_constant const } autoloaded_constants.clear Reference.clear! explicitly_unloadable_constants.each { |const| remove_constant const } end
autoloaded_constants
は上述のload_file
でロードされた定数を含む配列だ。remove_constant
はその名の通り定数を削除するメソッドで内部でModule#remove_const
を呼んでいる。
Qiita API v2のSwiftクライアントを書き始めた
昨日、Qiita API v2 Hackathonに参加してきた。
僕はハッカソンでnaoty/qiita-swiftを作った。Qiita API v2のSwiftクライアントだ。動機としては、Swiftはすごく楽しいのだけどJSONのパースあたりがとてもつらいという問題があって、そこらへんの地味な実装を代わりに担うライブラリを書くことで、楽しいSwiftの開発をより楽しくできると思った。
使い方
let client = Qiita.Client(accessToken: "...") let parameters = ["query": "user:naoty_k"] client.getItems(parameters: parameters).onComplete({ items in // ... }).onError({ error in // ... }).resume()
Promiseっぽい感じで非同期処理をメソッドチェーンで記述することができる。対応しているAPIは、ハッカソンで数時間で作っただけだから、まだユーザーと投稿のGETリクエストのみしかないので、今後追加していきたい。
ハッカソンについての所見
これまで何回かハッカソンに参加してきたけど、ハッカソンで作ったソフトウェアの開発をその後も続けた、ということはなかった。そのことがハッカソンに参加するたびに心にひっかかっていた。というのも、ハッカソンで開発するソフトウェアはなにかしらの問題を解決するために作っているはずで、その場かぎりの開発で解決できることはめったにないと思うからだ。ハッカソンは解決する価値のある問題を発見し、その解決策のプロトタイピングを短時間で行うイベントだと思う。だから、ハッカソンはきっかけであって解決に値する問題であればその後も継続的に開発していくべきなんだと思う。だから、開発しようと思った動機を大切にして、開発を継続していこうと思う。
Web API: The Good Partsを読んだ
- 作者: 水野貴明
- 出版社/メーカー: オライリージャパン
- 発売日: 2014/11/21
- メディア: 大型本
- この商品を含むブログ (1件) を見る
業務ではiOSアプリとバックエンドの開発を両方担当しているので、APIの設計を何回かやってきた。しかし、自分なりのやり方でやってきた部分が多かったので、最近発売されたWeb API: The Good Partsを読んでちゃんとした設計について学ぶことにした。
得られた学びをメモとして残す。
HATEOAS
HATEOAS(Hypermedia As The Engine Of Application State)という設計方法を初めて知った。HATEOASではまず、サーバー側はレスポンスに関連するエンドポイントを含め次にアクセスするAPIを簡単に辿れるようにする。クライアント側は最初のエンドポイント以外はハードコーディングせずにレスポンスに含まれるエンドポイントだけにアクセスを制限する。このようにすることで、アプリケーションのワークフローをクライアント側で制御するのではなく、サーバーサイドで制御できるようになる。
HATEOASのメリットとしては、特にモバイルアプリケーション開発の文脈で重要なこととして、最初にアクセスするものを除いてエンドポイントをハードコーディングせずに済むということだと思う。一度リリースしてしまったアプリにハードコーディングされたエンドポイントは互換性を死守しなければならないが、レスポンス中に含まれるものを使うのであれば柔軟にエンドポイントを変更することが可能になる。
例として、配列を返すレスポンスに前後のページへのリンクを含める場合はこのようになる。
{ friends: [ { id: 12345, name: "Alice" }, { id: 12346, name: "Bob" } ], hasNext: true, links: [ { uri: "http://api.example.com/v1/users?page=3&per_page=20", rel: "next" }, { uri: "http://api.example.com/v1/users?page=1&per_page=20", rel: "prev" } } }
エラー時のレスポンス
エラーが発生した場合は適切なHTTPステータスコードを返すのは当然としてエラーの詳細情報を返す必要がある。それらはレスポンスボディに含めることが多い。複数のエラーが同時に発生する場合を考慮して、詳細情報を配列で返す方が親切だと思う。詳細情報には、エラーメッセージだけでなく、API提供側で独自に定義した詳細コードが含まれることがある。詳細コードはステータスコードと混同しないように4桁にして、1000番台は汎用的なエラー、2000番台はユーザー情報に関連するエラーというようにカテゴリー分けすると便利。
{ errors: [ { message: "Not Found", code: 1013 } ] }
クライアント側の実装としては、HTTPステータスコードではなく詳細コードごとにエラーメッセージを出し分ける方がユーザーにとってフレンドリーなUIになるだろう。
キャッシュ
HTTPのキャッシュの仕様には2種類あり、期限切れモデルと検証モデルがある。
期限切れモデルでは、キャッシュの保存期限をサーバー側で指定し、クライアントはその期限中は通信を行わなくなり期限が切れたらアクセスを再開する。期限の指定方法はCache-Control
ヘッダーとExpires
ヘッダーの2種類ある。Cache-Control
はDate
ヘッダーの日時からの経過時間を指定する。Expires
は期限を表す日時を指定する。Cache-Control
は頻繁には更新されないデータに使われ、Expires
は天気情報など決まった時間に更新されることがわかっている場合に使われることが多い。
検証モデルでは、クライアントは今持っているキャッシュが有効かどうかを問い合わせて無効だったときだけ取得する。サーバー側はデータを返す際Last-Modified
ヘッダーとETag
ヘッダーを返し、クライアントに保持してもらう。そして、クライアントが問い合わせるときに送られたIf-Modified-Since
やIf-None-Match
と比較して変化しているかどうかを判定する。変化していればキャッシュは無効とみなし再取得させる。期限切れモデルとは異なり、毎回アクセスは発生しているため、データ自体がそこまで大きくないとあまり効果はない。
使い分けとしては、頻繁に更新されないものや定期的に更新されるようなデータは期限切れモデルを使い、頻繁に更新される可能性があるものは検証モデルを利用するのがよさそうだ。
その他
OAuth2.0のフロー、各HTTPステータスコードの説明、APIにまつわるセキュリティなどAPIを設計する上で必要となる周辺知識がのっている。個人的に気になっていたAPIオーケストレーションの話はそこまで取り上げられていなかった。
MDCSwipeToChooseを読んだ
前回に引き続き、UI周りのテクニックを学ぶためhttps://github.com/modocache/MDCSwipeToChooseを読む。海外で話題のTinder風のアプリを簡単に開発することができる。
まず使い方を簡単に見ていく。
override func viewDidLoad() { let options = MDCSwipeToChooseViewOptions() options.delegate = self options.likedText = "Like" options.likedColor = UIColor.blueColor() options.nopeText = "Nope" options.nopeColor = UIColor.redColor() options.onPan = { state in NSLog("Panning") } let swipableView = MDCSwipeToChooseView(frame: view.frame, options: options) view.addSubview(swipableview) } func viewDidCancelSwipe(view: UIView!) { NSLog("Cancel to choose") } func view(view: UIView!, wasChoosenWithDirection direction: MDCSwipeDirection) } NSLog("Choose to \(direction == .Left ? "Left" : "Right")") }
MDCSwipeToChooseViewOptions
オブジェクトにスワイプするViewの設定をまとめて初期化時に渡している。delegate
はMDCSwipeToChooseDelegate
を実装するオブジェクトである必要がある。likedText
とかnopeText
というのは、右もしくは左にスワイプされるときにView上に表示されるテキストのこと。onPan
はスワイプされているときに呼ばれる処理。
MDCSwipeToChooseDelegate
のメソッドとしてviewDidCancelSwipe()
とview(view:wasChoosenWithDirection:)
がある。前者はスワイプを途中でやめたとき、後者はスワイプしてViewをどちらかに選んだときに呼ばれる。
さらに、MDCSwipeToChooseView
だけではなく、UIView
をスワイプできるようにするカテゴリも用意されているため、より柔軟に実装できるようになっている。
今回、重点的に読んでいきたいのは以下のポイントだ。
- ライブラリの設計。カテゴリも含めた柔軟な実装を可能にするのは優れた設計があるからだと思うので参考にしたい。
- スワイプに合わせたViewの動き。
ライブラリの設計
このライブラリの作者の書いたiOS UI Component API Designという記事によると、設計において2点考慮されているようだ。
継承よりカテゴリーによるコンポジション
MDCSwipeToChooseView
に機能を追加したい場合、サブクラスを定義する必要がある。しかし、この方法では別のライブラリが提供するViewのもつ機能を組み込むことができない。そこで、カテゴリーでUIView
に機能を拡張することで、他のライブラリとも組み合わせることができる。
カテゴリーによる拡張の欠点はインスタンス変数を追加することができないことだ。そのため、プロパティをカテゴリーによって拡張する場合は、<objc/runtime.h>
のobjc_setAssociatedObject()
を使ったトリッキーな実装が必要になる。
より簡単に実装するには、カスタマイズ用のパラメータを束ねる設定オブジェクトを使うのがよさそう。この設定オブジェクトのプロパティだけは上記のトリッキーな手法で拡張するしかないが、Viewをカスタマイズする変数はすべてのこの設定オブジェクトに隠ぺいする。このライブラリでの設定オブジェクトはMDCSwipeOptions
とMDCSwipeToChooseViewOptions
だった。
パラメータオブジェクト
デリゲートメソッドやonPan
などのブロックのシグネチャがバージョンアップデートで変更されてしまうと互換性がなくなってしまう。そこで、複数の引数をまとめたパラメータオブジェクトというのを用意し、引数の変更をすべてパラメータオブジェクト内の変更で吸収することで、メソッドのシグネチャを変更せずに互換性を保つことができる。このライブラリではMDCPanState
がパラメータオブジェクトの役割を果たしている。
typedef void (^MDCSwipeToChooseOnPanBlock)(MDCPanState *state); @interface MDCSwipeOptions : NSObject // ... @property (nonatomic, copy) MDCSwipeToChooseOnPanBlock onPan; // ... @end
@interface MDCPanState : NSObject @property (nonatomic, strong) UIView *view; @property (nonatomic, assign) MDCSwipeDirection direction; @property (nonatomic, assign) CGFloat thresholdRatio; @end
初期化から表示まで
設計について確認したので、初期化から表示されるまでの流れからソースコードを読んでいく。
MDCSwipeToChooseView.m:44
- (instancetype)initWithFrame:(CGRect)frame options:(MDCSwipeToChooseViewOptions *)options { self = [super initWithFrame:frame]; if (self) { _options = options ? options : [MDCSwipeToChooseViewOptions new]; [self setupView]; [self constructImageView]; [self constructLikedView]; [self constructNopeImageView]; [self setupSwipeToChoose]; } return self; }
- まずこのクラスのスーパークラスは
UIView
なので、普通の初期化処理をしたあとにセットアップ処理を実行している。 - いくつかのセットアップ処理を順に見ていく。
MDCSwipeToChooseView.m:59
- (void)setupView { self.backgroundColor = [UIColor clearColor]; self.layer.cornerRadius = 5.f; self.layer.borderWidth = 2.f; self.layer.borderColor = [UIColor colorWith8BitRed:220.f green:220.f blue:220.f alpha:1.f].CGColor; }
self
の見た目に関する設定をしている。- 背景色が透明で、角丸で、枠線の太さと色を指定しているだけのようだ。
MDCSwipeToChooseView.m:69
- (void)constructImageView { _imageView = [[UIImageView alloc] initWithFrame:self.bounds]; _imageView.clipsToBounds = YES; [self addSubview:_imageView]; }
self
と同じ大きさの_imageView
を作ってサブビューに追加している。-[UIView clipsToBounds]
はサブビューを自分のbounds
のサイズで切り抜く設定だ。YES
を指定すると、サブビューの自分からはみ出た部分は表示されなくなる。
MDCSwipeToChooseView.m:75
- (void)constructLikedView { CGRect frame = CGRectMake(MDCSwipeToChooseViewHorizontalPadding, MDCSwipeToChooseViewTopPadding, CGRectGetMidX(_imageView.bounds), MDCSwipeToChooseViewLabelWidth); self.likedView = [[UIView alloc] initWithFrame:frame]; [self.likedView constructBorderedLabelWithText:self.options.likedText color:self.options.likedColor angle:self.options.likedRotationAngle]; self.likedView.alpha = 0.f; [self.imageView addSubview:self.likedView]; }
likedView
というのは右にスワイプしたときに浮かび上がるテキストのためのビュー。- 最初は非表示になっているので
alpha
が0
になっている。 -[UIView constructBorderedLabelWithText:color:angle]
というメソッドはUIView+MDCBorderedLabel.m
で定義されている拡張。
UIView+MDCBorderedLabel.m:31
- (void)constructBorderedLabelWithText:(NSString *)text color:(UIColor *)color angle:(CGFloat)angle { self.layer.borderColor = color.CGColor; self.layer.borderWidth = 5.f; self.layer.cornerRadius = 10.f; UILabel *label = [[UILabel alloc] initWithFrame:self.bounds]; label.text = [text uppercaseString]; label.textAlignment = NSTextAlignmentCenter; label.font = [UIFont fontWithName:@"HelveticaNeue-CondensedBlack" size:48.f]; label.textColor = color; [self addSubview:label]; self.transform = CGAffineTransformRotate(CGAffineTransformIdentity, MDCDegreesToRadians(angle)); }
- 角丸や枠線の設定をして、
UILabel
をサブビューに追加している。 -[UIView transform]
はcenter
またはanchorPoint
を基準とした変換値を表す。これを設定するとその変換が適用される。CGAffineTransformRotate()
は回転のためのアフィン変換行列を返す。第1引数に既存のアフィン変換、第2引数に回転角度を指定する。
MDCSwipeToChooseView.m:88
- (void)constructNopeImageView { CGFloat width = CGRectGetMidX(self.imageView.bounds); CGFloat xOrigin = CGRectGetMaxX(_imageView.bounds) - width - MDCSwipeToChooseViewHorizontalPadding; self.nopeView = [[UIImageView alloc] initWithFrame:CGRectMake(xOrigin, MDCSwipeToChooseViewTopPadding, width, MDCSwipeToChooseViewLabelWidth)]; [self.nopeView constructBorderedLabelWithText:self.options.nopeText color:self.options.nopeColor angle:self.options.nopeRotationAngle]; self.nopeView.alpha = 0.f; [self.imageView addSubview:self.nopeView]; }
- こちらは左にスワイプしたときに浮かび上がるテキストのビュー。
_likedView
と大差ない。
MDCSwipeToChooseView.m:102
- (void)setupSwipeToChoose { MDCSwipeOptions *options = [MDCSwipeOptions new]; options.delegate = self.options.delegate; options.threshold = self.options.threshold; __block UIView *likedImageView = self.likedView; __block UIView *nopeImageView = self.nopeView; __weak MDCSwipeToChooseView *weakself = self; options.onPan = ^(MDCPanState *state) { if (state.direction == MDCSwipeDirectionNone) { likedImageView.alpha = 0.f; nopeImageView.alpha = 0.f; } else if (state.direction == MDCSwipeDirectionLeft) { likedImageView.alpha = 0.f; nopeImageView.alpha = state.thresholdRatio; } else if (state.direction == MDCSwipeDirectionRight) { likedImageView.alpha = state.thresholdRatio; nopeImageView.alpha = 0.f; } if (weakself.options.onPan) { weakself.options.onPan(state); } }; [self mdc_swipeToChooseSetup:options]; }
MDCSwipeOptions
オブジェクトを生成して、初期化時に渡されたself.options
のプロパティをコピーしている。onPan
ブロック内で最後に初期化時に渡されたself.options.onPan
が実行されるようになっている。__block
属性はブロック内で変更する場合に変数につける必要がある。onPan
内の処理を詳しく見ていく。- ブロックの引数に渡される
state
のdirection
プロパティはMDCSwipeDirection
型の値で、None
,Left
,Right
のいずれかだ。Left
なら`nopeImageView
のアルファ値を変更し表示されるようにしている。逆にRight
ならlikedImageView
を同様にして表示されるようにしている。 state
のthresholdRatio
プロパティはコメントによると、ある閾値にどれだけ近づいているかを表す、0
から1
までの値だ。1
のとき閾値に達したことを意味する。よって、ある閾値に達したときthresholdRatio
が1
になり、likedImageView
またはnopeImageView
のアルファ値が1
になって完全に表示されるようになる。
- ブロックの引数に渡される
-[UIView mdc_swipeToChooseSetup:]
はUIView+MDCSwipeToChoose.m
で定義されたカテゴリーによって拡張されたメソッドだ。このライブラリはMDCSwipeToChooseView
という専用のクラスだけでなく、UIView
のカテゴリーを提供することでより柔軟に実装できるようになっているが、その中心部分はこのカテゴリー内で実装しているようだ。
UIView+MDCSwipeToChoose.m:38
- (void)mdc_swipeToChooseSetup:(MDCSwipeOptions *)options { self.mdc_options = options ? options : [MDCSwipeOptions new]; self.mdc_viewState = [MDCViewState new]; self.mdc_viewState.originalCenter = self.center; [self mdc_setupPanGestureRecognizer]; }
self.mdc_options
とself.mdc_viewState
を初期化している。-[UIView mdc_setupPanGestureRecognizer]
でジェスチャーのイベントハンドリングを実装しているのだろう。
スワイプに合わせたViewの動き
これまでMDCSwipeToChooseView
およびUIView+MDCSwipeToChoose
による拡張部分の初期化について見てきた。これからスワイプに合わせてViewをどのように動かしているのかについて詳細に見ていく。
UIView+MDCSwipeToChoose.m:104
- (void)mdc_setupPanGestureRecognizer { SEL action = @selector(mdc_onSwipeToChoosePanGestureRecognizer:); UIPanGestureRecognizer *panGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:action]; [self addGestureRecognizer:panGestureRecognizer]; }
UIPanGestureRecognizer
を初期化してUIView
に追加している。パンというジェスチャーはスワイプとかドラッグのことだ。- スワイプされると
-[UIView mdc_onSwipeToChoosePanGestureRecognizer:]
が呼ばれるようだ。
UIView+MDCSwipeToChoose.m:227
- (void)mdc_onSwipeToChoosePanGestureRecognizer:(UIPanGestureRecognizer *)panGestureRecognizer { UIView *view = panGestureRecognizer.view; if (panGestureRecognizer.state == UIGestureRecognizerStateBegan) { self.mdc_viewState.originalCenter = view.center; // If the pan gesture originated at the top half of the view, rotate the view // away from the center. Otherwise, rotate towards the center. if ([panGestureRecognizer locationInView:view].y < view.center.y) { self.mdc_viewState.rotationDirection = MDCRotationAwayFromCenter; } else { self.mdc_viewState.rotationDirection = MDCRotationTowardsCenter; } } else if (panGestureRecognizer.state == UIGestureRecognizerStateEnded) { // Either move the view back to its original position or move it off screen. [self mdc_finalizePosition]; } else { // Update the position and transform. Then, notify any listeners of // the updates via the pan block. CGPoint translation = [panGestureRecognizer translationInView:view]; view.center = MDCCGPointAdd(self.mdc_viewState.originalCenter, translation); [self mdc_rotateForTranslation:translation rotationDirection:self.mdc_viewState.rotationDirection]; [self mdc_executeOnPanBlockForTranslation:translation]; } }
- スワイプが始まったとき、ユーザーの指の位置がViewの上半分なら
MDCRotationAwayFromCenter
すなわち1.0
、下半分ならMDCRotationTowardsCenter
すなわち-1.0
をself.mdc_viewState.rotationDirection
にセットしている。 - スワイプが終わったとき、
-[UIVIew mdc_finalizePosition]
を呼ぶ。ここはあとで詳細に見ることにしてスキップする。 - スワイプ中
-[UIPanGestureRecognizer translationInView:]
によって最初に指が触れた点からの移動量を取得している。- 取得した移動量を
originalCenter
に加えた値をcenter
とすることで、ユーザーの指の位置が常にcenter
になるようにViewを移動させているようだ。 -[UIView mdc_rotateForTranslation:rotationDirection:]
によってViewを回転させているようだ。あとで詳細を見ることにする。-[UIView mdc_executeOnPanBlockForTranslation:]
はスワイプの状態からthresholdRatio
を計算したりMDCPanState
を生成したりしてself.mdc_options.onPan()
の引数に渡して実行している。ここで、Viewの初期化時に指定したonPan
のブロックが実行されることになる。
UIView+MDCSwipeToChoose.m:189
後回しにしていた-[UIView mdc_rotateForTranslation:rotationDirection:]
を先に見る。
- (void)mdc_rotateForTranslation:(CGPoint)translation rotationDirection:(MDCRotationDirection)rotationDirection { CGFloat rotation = MDCDegreesToRadians(translation.x/100 * self.mdc_options.rotationFactor); self.transform = CGAffineTransformRotate(CGAffineTransformIdentity, rotationDirection * rotation); }
x軸方向への移動量 / 100
に定数倍したものをラジアンに変換して、rotationDirection
(1.0
or-1.0
)を掛けた量を回転させている。
UIView+MDCSwipeToChoose.m:114
次に、スワイプが終了したときに呼ばれる-[UIView mdc_finalizePosition]
を見ていく。
- (void)mdc_finalizePosition { MDCSwipeDirection direction = [self mdc_directionOfExceededThreshold]; switch (direction) { case MDCSwipeDirectionRight: case MDCSwipeDirectionLeft: { CGPoint translation = MDCCGPointSubtract(self.center, self.mdc_viewState.originalCenter); [self mdc_exitSuperviewFromTranslation:translation]; break; } case MDCSwipeDirectionNone: [self mdc_returnToOriginalCenter]; [self mdc_executeOnPanBlockForTranslation:CGPointZero]; break; } }
-[UIView mdc_directionOfExceededThreshold]
である閾値を超えた方向を取得しているようだ。- 取得した方向が左か右であれば
-[UIView mdc_exitSuperviewFromTranslation:]
を呼び、どちらでもなかった場合は-[UIView mdc_returnToOriginalCenter]
と-[UIView mdc_executeOnPanBlockForTranslation:]
を呼んでいる。
UIView+MDCSwipeToChoose.m:215
まず閾値をを超えた方向を取得する部分から見ていく。
- (MDCSwipeDirection)mdc_directionOfExceededThreshold { if (self.center.x > self.mdc_viewState.originalCenter.x + self.mdc_options.threshold) { return MDCSwipeDirectionRight; } else if (self.center.x < self.mdc_viewState.originalCenter.x - self.mdc_options.threshold) { return MDCSwipeDirectionLeft; } else { return MDCSwipeDirectionNone; } }
- どうやら閾値というのは
self.mdc_options.threshold
のことのようだ。デフォルトでは100.0
だ。 - Viewの中心点のx座標がもともとの中心点のx座標から閾値以上移動した場合、右方向なら
Right
、左方向ならLeft
を返している。そうでなければNone
を返している。
UIView+MDCSwipeToChoose.m:146
次に、上記の閾値を超えてどちらかの方向が返ってきた場合に呼ばれる-[UIView mdc_exitSuperviewFromTranslation:]
を見る。
- (void)mdc_exitSuperviewFromTranslation:(CGPoint)translation { MDCSwipeDirection direction = [self mdc_directionOfExceededThreshold]; id<MDCSwipeToChooseDelegate> delegate = self.mdc_options.delegate; if ([delegate respondsToSelector:@selector(view:shouldBeChosenWithDirection:)]) { BOOL should = [delegate view:self shouldBeChosenWithDirection:direction]; if (!should) { return; } } MDCSwipeResult *state = [MDCSwipeResult new]; state.view = self; state.translation = translation; state.direction = direction; state.onCompletion = ^{ if ([delegate respondsToSelector:@selector(view:wasChosenWithDirection:)]) { [delegate view:self wasChosenWithDirection:direction]; } }; self.mdc_options.onChosen(state); }
delegate
にview:shouldBeChosenWithDirection:
が実装されていれば、それを呼びNO
が返ってきた場合そこで終了する。MDCSwipeResult
オブジェクトを初期化してself.mdc_options.onChosen()
に渡して実行している。
MDCSwipeOptions.m:33
onChosen
は何を参照しているのか確認する。
- (instancetype)init { self = [super init]; if (self) { _swipeCancelledAnimationDuration = 0.2; _swipeCancelledAnimationOptions = UIViewAnimationOptionCurveEaseOut; _swipeAnimationDuration = 0.1; _swipeAnimationOptions = UIViewAnimationOptionCurveEaseIn; _rotationFactor = 3.f; _onChosen = [[self class] exitScreenOnChosenWithDuration:0.1 options:UIViewAnimationOptionCurveLinear]; } return self; }
_onChosen
は+[MDCSwipeOptions exitScreenOnChosenWithDuration:options]
の返り値を参照している。
MDCSwipeOptions.m:50
+ (MDCSwipeToChooseOnChosenBlock)exitScreenOnChosenWithDuration:(NSTimeInterval)duration options:(UIViewAnimationOptions)options { return ^(MDCSwipeResult *state) { CGRect destination = MDCCGRectExtendedOutOfBounds(state.view.frame, state.view.superview.bounds, state.translation); [UIView animateWithDuration:duration delay:0.0 options:options animations:^{ state.view.frame = destination; } completion:^(BOOL finished) { if (finished) { [state.view removeFromSuperview]; state.onCompletion(); } }]; }; }
- このメソッドはブロックを返しているのであって、ブロックを実行しているわけではない。
- その内容としては、Viewをスーパービューの外にアニメーションつきで移動させ、完了後にそのViewをスーパービューから削除し、
state.onCompletion()
を実行するというものだ。
UIView+MDCSwipeToChoose.m:146
いったん-[UIView mdc_exitSuperviewFromTranslation:]
に戻ってonCompletion
を確認する。
- (void)mdc_exitSuperviewFromTranslation:(CGPoint)translation { // ... MDCSwipeResult *state = [MDCSwipeResult new]; state.view = self; state.translation = translation; state.direction = direction; state.onCompletion = ^{ if ([delegate respondsToSelector:@selector(view:wasChosenWithDirection:)]) { [delegate view:self wasChosenWithDirection:direction]; } }; self.mdc_options.onChosen(state); }
- Viewが枠外に消えた後に、
onChosen()
の引数に渡されたstate
のonCompletion
が実行されるので、ここではdelegate
のview:wasChosenWithDirection:
が呼ばれることになる。
UIView+MDCSwipeToChoose.m:131
続いて、-[UIView mdc_finalizePosition]
で閾値を超えなかった場合に呼ばれる2つのメソッドのうち、-[UIView mdc_returnToOriginalCenter]
を見る。
- (void)mdc_returnToOriginalCenter { [UIView animateWithDuration:self.mdc_options.swipeCancelledAnimationDuration delay:0.0 options:self.mdc_options.swipeCancelledAnimationOptions animations:^{ self.transform = CGAffineTransformIdentity; self.center = self.mdc_viewState.originalCenter; } completion:^(BOOL finished) { id<MDCSwipeToChooseDelegate> delegate = self.mdc_options.delegate; if ([delegate respondsToSelector:@selector(viewDidCancelSwipe:)]) { [delegate viewDidCancelSwipe:self]; } }]; }
- アニメーションつきで回転を打ち消し、もともとの中心点に移動させている。
- それらが完了したあと、
delegate
のviewDidCancelSwipe:
を呼んでいる。
UIView+MDCSwipeToChoose.m:168
もう1つの-[UIView mdc_executeOnPanBlockForTranslation:]
を見る。
- (void)mdc_executeOnPanBlockForTranslation:(CGPoint)translation { if (self.mdc_options.onPan) { CGFloat thresholdRatio = MIN(1.f, fabsf(translation.x)/self.mdc_options.threshold); MDCSwipeDirection direction = MDCSwipeDirectionNone; if (translation.x > 0.f) { direction = MDCSwipeDirectionRight; } else if (translation.x < 0.f) { direction = MDCSwipeDirectionLeft; } MDCPanState *state = [MDCPanState new]; state.view = self; state.direction = direction; state.thresholdRatio = thresholdRatio; self.mdc_options.onPan(state); } }
- 中心点に戻る際の
onPan
ブロックを実行している。そのために、thresholdRatio
を計算しMDCPanState
を初期化している。