2017年3月31日金曜日

アイコンフォントを使って、ボタンのdrawableLeft/Rightに画像を設定する方法(Iconics編)

このエントリーをはてなブックマークに追加

概要

アイコンフォント便利ですよね! アイコンフォントを使うと、テキストの中でアイコン画像を表示することができます。 つまり、アイコン+テキストのボタン等が簡単に作れます。 ただ、一つの要素として表示されるため、テキストとアイコンとで違うサイズを使えなかったり、アイコンとテキストでベースラインが揃ってしまう問題があります。

Androidのボタンは、drawableLeftやdrawableRight属性を使うことで、ボタン内にアイコンを表示できます。 この属性を使うと先の問題が解決できそうですが、イメージを指定する必要があるため、そのままでは使えません。

そこで、アイコンフォントをdrawable vectorに変換して、アイコンとして表示する方法について説明します。

アイコンフォントを使うためのライブラリとしては、Iconicsを利用します。

結果

下記のようにdrawable_icon_font_left属性を指定すると、いい感じにアイコンを表示してくれます。

<Button
    android:text="{faw-android} android"
    />
<Button
    android:text="android"
    app:drawable_icon_font_left="@{`faw_android`}"
    />

image1

仕組み

IconicsのインストールとdataBindingの有効化

build.gradle で下記の設定を追加します。ここでは例として、AwesomeFontを使ってみます。

android {
    ...
    dataBinding {
        enabled = true
    }
}

dependencies {
    ...
    compile "com.mikepenz:iconics-core:2.8.2@aar"
    compile 'com.mikepenz:fontawesome-typeface:4.7.0.0@aar'
}

Iconicsの有効化

利用するActivityやFragmentで下記の設定を追加します。

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        LayoutInflaterCompat.setFactory(getLayoutInflater(), new IconicsLayoutInflater(getDelegate()));

        super.onCreate(savedInstanceState);
        DataBindingUtil.setContentView(this, R.layout.activity_main);
    }
}

BindingAdapterの定義

drawable_icon_font_left 属性を定義します。

メソッド内では、指定された文字からDrawableを生成します。 その際に、好みに合わせて、テキストと同じカラーを設定したり、アイコンサイズをテキストサイズと比べて少し大きいものを設定できます。

public class BindingAdapterManager {

    @BindingAdapter("drawable_icon_font_left")
    public static void setDrawableIconFontLeft(final Button button, final String icon) {
        final Context context = button.getContext();
        final IconicsDrawable iconicsDrawable = new IconicsDrawable(context)
                .icon(FontAwesome.Icon.valueOf(icon))
                .color(button.getCurrentTextColor())
                .sizePx((int)(button.getTextSize() * 1.25));
        button.setCompoundDrawables(iconicsDrawable, null, null, null);
    }
}

サンプル

Drawable-Icon-Font@githubに動作するプロジェクトがあります。

バリデーション機能付きのTextInputLayoutの実装方法

このエントリーをはてなブックマークに追加

概要

AndroidのTextInputLayoutを拡張し、バリデーション機能付きのValidationTextInputLayoutの実装方法について説明します。 ValidationTextInputLayoutではカスタム属性を追加することで、必須、バリデーション項目、エラーメッセージをxml上から指定できます。

動作例

animation

目指すべき形

下記は、Emailアドレスの入力欄を必須にし、入力内容がEmail形式か否かをチェックします。 Email内容でない場合には、error_textで指定した文字列を表示します。

<ValidationTextInputLayout
    app:required="true"
    app:validation_type="email"
    app:error_text="Email value is invalidated"
    >
    <EditText
        android:hint="Email (Required)"
        />
</ValidationTextInputLayout>

仕組み

カスタム属性の定義

res/values/attrs.xmlにカスタム属性を定義します。 後で定義するValidationTextInputLayoutでは、属性を受け取り挙動を変更します。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ValidationTextInputLayout">
        <attr name="required" format="boolean" />
        <attr name="validation_type">
            <enum name="post_code" value="0" />
            <enum name="email" value="1" />
        </attr>
        <attr name="error_text" format="string" />
    </declare-styleable>
</resources>

バリデーションタイプの定義

ValidationTypeを列挙型として定義します。 この処理は必須ではないですが、ソースコードの見通しが向上します。 enumの値とattrs.xmlで定義した値は一致させる必要があります。

public enum ValidationType {
    PostCode(0), Email(1),
    Null(9999);
    private int value;

    ValidationType(int value) {
        this.value = value;
    }

    public int getValue() {
        return this.value;
    }

    public static ValidationType valueOf(final int value) {
        ValidationType type = Null;
        for (ValidationType validationType : ValidationType.values()) {
            if (validationType.getValue() == value) {
                type = validationType;
                break;
            }
        }
        return type;
    }
}

TextInputLayoutの拡張

initAttrs()で指定された属性値を取得します。 updateError()では、バリデーションを行い、必要に応じてエラーメッセージを出力します。 外部からは、isValidated()を呼び出すことで、バリデーションが通ったかを判断できます。

public class ValidationTextInputLayout extends TextInputLayout {
    private static final String PATTERN_POST_CODE = "\\d{7}";

    private boolean isRequired = false;
    private ValidationType validationType = ValidationType.Null;
    private String errorText;

    public ValidationTextInputLayout(Context context) {
        super(context);
    }

    public ValidationTextInputLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        initAttrs(context, attrs);
    }

    public ValidationTextInputLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initAttrs(context, attrs);
    }

    private void initAttrs(final Context context, AttributeSet attrs) {
        final TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.ValidationTextInputLayout);

        isRequired = typedArray.getBoolean(R.styleable.ValidationTextInputLayout_required, false);

        final int validationTypeValue = typedArray.getInt(
                R.styleable.ValidationTextInputLayout_validation_type, ValidationType.Null.getValue());
        validationType = ValidationType.valueOf(validationTypeValue);

        String errorText = typedArray.getString(R.styleable.ValidationTextInputLayout_error_text);
        if (TextUtils.isEmpty(errorText)) {
            errorText = getContext().getString(R.string.error_default_text);
        }
        this.errorText = errorText;
    }

    public boolean isValidated() {
        updateError();

        final boolean isValidated = TextUtils.isEmpty(getError());
        setErrorEnabled(!isValidated);
        return isValidated;
    }

    private void updateError() {
        final String text = getEditText().getText().toString();
        final boolean isEmpty = TextUtils.isEmpty(text);
        setError(null);

        switch (validationType) {
            case PostCode:
                if (!isPostCode(text)) {
                    setError(errorText);
                }
                break;
            case Email:
                if (!isEmail(text)) {
                    setError(errorText);
                }
                break;
            default:
                break;
        }

        if (isEmpty) {
            if (isRequired) {
                setError("Fill in this form");
            } else {
                setError(null);
            }
        }
    }

    private boolean isPostCode(final String str) {
        return Pattern.compile(PATTERN_POST_CODE).matcher(str).matches();
    }

    private boolean isEmail(final String str) {
        return Patterns.EMAIL_ADDRESS.matcher(str).matches();
    }
}

サンプル

Validation-Text-Input-Layout@githubに動作するプロジェクトがあります。

iOS版

同じような処理をiOSでも実装しています。 バリデーション&エラー表示付きの入力フォームを作ってみる を御覧ください。

2017年3月30日木曜日

orhanobut/loggerのリリースバージョンで表示するログレベルの設定方法

このエントリーをはてなブックマークに追加

概要

orhanobut/loggerは、Androidでログを表示するための便利なライブラリです。 ここでは、リリースバージョンでエラーログのみを表示するように、ログレベルを設定する方法について説明します。

設定方法

表示するログレベルの定義

LogAdapterを実装したクラスを用意します。BuildConfig.DEBUGは、リリースバージョンではfalseとなるため、ここではエラーレベルのログ以外のものはリリースバージョンでは表示しないようにします。

public class MyLogAdapter implements LogAdapter {
    @Override
    public void v(String tag, String message) {
        if (BuildConfig.DEBUG) {
            Log.v(tag, message);
        }
    }

    @Override
    public void d(String tag, String message) {
        if (BuildConfig.DEBUG) {
            Log.d(tag, message);
        }
    }

    @Override
    public void i(String tag, String message) {
        if (BuildConfig.DEBUG) {
            Log.i(tag, message);
        }
    }

    @Override
    public void w(String tag, String message) {
        if (BuildConfig.DEBUG) {
            Log.w(tag, message);
        }
    }

    @Override
    public void e(String tag, String message) {
        Log.e(tag, message);
    }

    @Override
    public void wtf(String tag, String message) {
        if (BuildConfig.DEBUG) {
            Log.wtf(tag, message);
        }
    }
}

Loggerの初期化

Application等において、Loggerを初期化し、MyLogAdapterを設定します。

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();

        Logger.init(getString(R.string.app_name)).logAdapter(new MyLogAdapter());
    }
}

使ってみる!

Logger.d("Displayed only debug build");

下記は、Loggerを使った際の出力例です。 デバッグバージョンのみ出力されます。

image1

サンプル

Logger@githubに動作するプロジェクトがあります。

お気に入り状態などを複数の画面で同期させる方法

このエントリーをはてなブックマークに追加

概要

アイテムをテーブルビューで一覧表示し、タップすると詳細画面に飛ぶ。 詳細画面でアイテムを「お気に入り」して、前の画面に戻ると、一覧ではお気に入りになってない。 みたいなパターンをよく作ると思います。

また、NSNotificationを使って「お気に入り」イベントを通知し、ビューの状態を変更しても、元のモデルが変更されていないため、テーブルビューをスクロールして再描画した時に、お気に入り状態が戻ってしまったりします。

このような場合には、「モデルを修正→モデルに基づいてビューを再描画する」という手順を守る必要があります。

動作例

animation

目指すべき形

下記ViewControllerでは、viewDidLoadでモデルの変更通知を受け取ります。 ModelChangeableを実装することで、モデルの変更を受け取り、modelsを変更すること、テーブルビューを再描画します。 こうすることで、データとビューに差が生まれることを防げきます。

class ViewController: UIViewController {
    @IBOutlet fileprivate weak var tableView: UITableView!

    var models = [Model]() {
        didSet {
            tableView?.reloadData()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        addDidChangeModelObserver()
    }

    deinit {
        removeDidChangeModelObserver()
    }
}

extension ViewController: ModelChangeable {
    func updateModel(_ model: Model) {
        models = models.map({ $0.id == model.id ? model : $0 })
    }

    func deleteModel(_ model: Model) {
        models = models.filter({ $0.id != model.id })
    }
}

構成

Model

サンプルのモデルではidとお気に入り状態を保持します。

struct Model {
    let id: Int
    var isFavorite: Bool
}

ModelChangeable

変更されたモデル受け取るクラスが実装すべきプロトコルを宣言します。 今回のサンプルでは、モデルのUpdateとDelete操作に対応するとします。

protocol ModelChangeable {
    func updateModel(_ model: Model)
    func deleteModel(_ model: Model)
}

ModelNotification

通知を送信したり、受信した通知のペイロードから値を取得するUtilityです。

enum NotificationEventType: Int {
    case update
    case delete
}

private enum NotificationInfoKey: String {
    case model
    case eventType
}

extension NSNotification.Name {
    static let didChangeModel = Notification.Name(rawValue: "changed_model")
}

struct ModelNotification {
    static func update(model: Model) {
        post(model: model, eventType: .update)
    }

    static func delete(model: Model) {
        post(model: model, eventType: .delete)
    }

    private static func post(model: Model, eventType: NotificationEventType) {
        let userInfo: [String: Any] = [
            NotificationInfoKey.model.rawValue: model,
            NotificationInfoKey.eventType.rawValue: eventType
        ]
        DispatchQueue.main.async {
            NotificationCenter.default.post(name: .didChangeModel, object: nil, userInfo: userInfo)
        }
    }

    static func getModel(from notification: Notification) -> Model? {
        guard
            let userInfo = notification.userInfo,
            let model = userInfo[NotificationInfoKey.model.rawValue] as? Model else {
                return nil
        }
        return model
    }

    static func getEventType(from notification: Notification) -> NotificationEventType? {
        guard
            let userInfo = notification.userInfo,
            let eventType = userInfo[NotificationInfoKey.eventType.rawValue] as? NotificationEventType else {
                return nil
        }
        return eventType
    }
}

ModelObserving

モデル変更を受け取りたいクラスが実装すべき関数を定義したプロトコルです。 例では、ViewControllerについて実装しています。

protocol ModelObserving {
    func addDidChangeModelObserver(notificationCenter: NotificationCenter)
    func removeDidChangeModelObserver(notificationCenter: NotificationCenter)
    func didChangeModel(_ notification: Notification)
}

// MARK: - UIViewController

extension UIViewController: ModelObserving {
    func addDidChangeModelObserver(notificationCenter: NotificationCenter = NotificationCenter.default) {
        notificationCenter.addObserver(self, selector: #selector(didChangeModel(_:)), name: .didChangeModel, object: nil)
    }

    func removeDidChangeModelObserver(notificationCenter: NotificationCenter = NotificationCenter.default) {
        notificationCenter.removeObserver(self, name: .didChangeModel, object: nil)
    }

    func didChangeModel(_ notification: Notification) {
        guard
            let modelChangeableThing = self as? ModelChangeable,
            let model = ModelNotification.getModel(from: notification),
            let eventType = ModelNotification.getEventType(from: notification) else {
                return
        }

        switch eventType {
        case .update:
            modelChangeableThing.updateModel(model)
        case .delete:
            modelChangeableThing.deleteModel(model)
        }
    }
}

通知の発行

詳細画面において、モデルの状態に変更があった時に、通知を発行します。 そうすると、ModelChangeableを実装したViewControllerのupdateModelやdeleteModelが呼び出されます。

class DetailViewController: UIViewController {    
    @IBAction private func onFavoriteButtonClick(_ sender: UIButton) {
        ModelNotification.update(model: model)
    }

    @IBAction private func onDeleteButtonClick(_ sender: UIButton) {
        ModelNotification.delete(model: model)
    }
}

サンプル

Synchronize-Model@githubに動作するプロジェクトがあります。

2017年3月29日水曜日

バリデーション&エラー表示付きの入力フォームを作ってみる

このエントリーをはてなブックマークに追加

概要

animation

この記事では、AndroidのTextInputLayoutのように、エラー表示付きの入力フォームを作る方法について説明します。ついでに、入力値のバリデーションも行います。

サンプル

Text-Input-Layout@githubに動作するプロジェクトがあります。

仕組み

TextInputView

image1

タイトルラベル、必須マークラベル、テキストフィールド、エラーラベルを持つカスタムビューとTextInputViewを用意します。

クラス全体をIBDesignable、title, isRequired, placeholder, errorMessageを@IBInspectableとし、Interface Builderから値を入力できるようにします。 また、computed propertyとして、isValidatedを宣言します。isValidatedを呼ぶと、バリデーションを行い、必要に応じてエラーを表示します。 これにより、呼び出し元は、TextInputValidationTypeを最初に設定すれば、バリデーションロジックやエラーの表示・非表示を気にする必要はありません。

enum TextInputValidationType: Int {
    case none
    case email
}

@IBDesignable
class TextInputView: UIView {
    @IBOutlet fileprivate weak var titleLabel: UILabel!
    @IBOutlet fileprivate weak var titleViewWidthConstraint: NSLayoutConstraint!
    @IBOutlet fileprivate weak var requiredLabel: UILabel!
    @IBOutlet weak var textField: UITextField!
    @IBOutlet fileprivate weak var errorLabel: UILabel!

    @IBInspectable var title: String = "" {
        didSet {
            titleLabel?.text = title
            titleLabel?.sizeToFit()
            titleViewWidthConstraint?.constant = titleLabel.frame.width
        }
    }
    @IBInspectable var isRequired: Bool = true {
        didSet {
            requiredLabel?.isHidden = !isRequired
        }
    }
    @IBInspectable var placeholder: String = "" {
        didSet {
            textField?.placeholder = placeholder
        }
    }
    @IBInspectable var errorMessage: String = "" {
        didSet {
            errorLabel?.text = errorMessage
        }
    }

    var validationType: TextInputValidationType = .none
    var text: String {
        return textField?.text ?? ""
    }
    var isValidated: Bool {
        errorLabel.isHidden = true
        switch validationType {
        case .email:
            errorLabel.isHidden = isEmail(text: text)
        default:
            break
        }

        if text.isEmpty {
            errorLabel.isHidden = !isRequired
        }

        return errorLabel.isHidden
    }
}

TextInputViewの使い方

Interface Builder上でUIViewを追加し、ClassをTextInputViewに設定します。 そうすると、IBInspectableを設定したプロパティをInterface Builder上から操作できることがわかります。isRequiredをOn(true)にすると、(Required)が表示されていることがわかります。

image2

逆にisRequiredをOff(false)にすると、(Required)が非表示になったことがわかります。

image3

呼び出し元では、validationTypeやtextFieldのキーボードタイプを指定します。 また、isValidatedを呼び出すことで、バリデーション条件をクリアしているかを知ることができます。 このように、TextInputView側にバリデーションやエラー表示の処理を任せることで、呼び出し元は、スッキリとしたコードとなります。 (ちなみに、IBInspectableに、enum型を指定することができれば、より簡易に書けそうですが、現状はできません。)

class ViewController: UIViewController {
    @IBOutlet fileprivate weak var emailTextInputView: TextInputView!
    @IBOutlet fileprivate var textInputViews: [TextInputView]!
    @IBOutlet fileprivate weak var resultLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()

        emailTextInputView.validationType = .email
        emailTextInputView.textField.keyboardType = .emailAddress
        emailTextInputView.textField.returnKeyType = .done
    }

    @IBAction private func onValidationButtonClick(_ sender: UIButton) {
        let areValidated = !textInputViews.map({ $0.isValidated }).contains(false)
        resultLabel.text = areValidated ? "Validated :)" : "Invalidated :("
    }
}

サンプル

Text-Input-Layout@githubに動作するプロジェクトがあります。

制約の修正なしで縦方向に要素を追加可能なビューの作成

このエントリーをはてなブックマークに追加

概要

タイトルが長ったらしいですが、オートレイアウトでビューを組版している時、後から機能追加が発生し、「途中にビューを追加したい!」、「最後にビューを追加したい!」ってなると非常に面倒です。 そこで、Scroll ViewとStack Viewを組み合わせることで、AndroidでいうLinearLayoutっぽいものを実現できます。

image9

構築手順

1.Scroll Viewの配置

基盤となるViewの全画面にScroll Viewを配置します。

image1

Add New Constraintsで上下左右全てのマージンを0とします。

image2

2.Stack Viewの配置

Scroll viewの子要素として、Vertical Stack Viewを配置します。 手順1と同様に、Add New Constraintsで上下左右全てのマージンを0とします。

image3

しかし、このままではScroll Viewのコンテントサイズの制約がないためエラーがでます。 image4

3.Scroll Viewコンテンツサイズの横幅の制約

Controlキーを押しながらStack Viewを選択し、Scroll Viewへドラッグします。 image5

Equla Widths を選択し、Stack Viewの横幅をScroll Viewコンテントの横幅と揃える制約をつけます。

image6

4.Scroll Viewコンテンツサイズの高さ(=Stack Viewの高さ)の制約

手順2により、Scroll Viewコンテンツサイズの高さ=Stack Viewの高さなため、Stack Viewの高さを決定します。様々なやり方があるのですが、ここでは、Stack Viewに配置されているビュー間のマージンが同じになるDistribution=Equal Spacingを例に取り説明します。

image7

Stack Viewの子要素として適当なViewを追加します。 このViewの高さに制約を設けます。

image8

Viewの高さが決定できると、Stack Viewの高さが決定でき、Scroll Viewのコンテンツサイズの高さが決まるため、エラーがなくなります。

以後、このStack Viewの子要素として、高さが決定しているViewを追加すれば、他のビューの制約を修正する必要はなくなります。

今回の例では、縦方向に要素を追加するサンプルについて説明しましたが、Horizontal Stack Viewを使えば同様の処理を横方向についても実現できます。

サンプル

Scroll-Stack-Content@githubに動作するプロジェクトがあります。

2017年3月27日月曜日

Instagramライクなパン/ピンチ操作できるイメージビューの作成

このエントリーをはてなブックマークに追加

概要

Instagramアプリのようなパン/ピンチ操作を受け取り、拡大/移動ができるイメージビューを作成する方法です。 サンプルでは、パン操作とピンチ操作を使用していますが、同じ方法で回転(Rotation gesture)することもできます。

animation

Interface Builder構築手順

1.フィルタービューの追加

イメージビューの下の階層に透明なビューを用意します。 イメージビューが操作を受け取り拡大率に応じて、フィルタービューの背景色を濃くすることで画像を見やすくします。

image1

2.イメージビューの追加

フィルタービューの上の階層にイメージビューを配置します。 User Interaction EnabledとMultiple Touchにチェックをします。

image2

3.パン/ピンチジェスチャーの追加

Pan Gesture RecognizerおよびPinch Gesture Recognizerをイメージビューの上にドラッグ&ドロップします。 また、delegateおよびIBActionをViewControllerにセットします。

image3

4.パンジェスチャーのプロパティ

1本指では、パン操作を受け付けたくない場合には、Touchesを2に変更します。

image4

プログラム説明

TransformProperty構造体

イメージビューの変形状態を保持します。

fileprivate struct TransformProperty {
    private let kMaxBackgroundAlpha: CGFloat = 0.77
    private let kMinBackgroundAlpha: CGFloat = 0.4

    var point: CGPoint
    var scale: CGFloat
    var backgroundAlpha: CGFloat {
        didSet {
            // Round the value
            backgroundAlpha = min(kMaxBackgroundAlpha, max(kMinBackgroundAlpha, backgroundAlpha))
        }
    }

    init() {
        point = CGPoint(x: 0, y: 0)
        scale = 1.0
        backgroundAlpha = kMinBackgroundAlpha
    }
}

ViewControllerとIBAction

onPinchGestureとonPanGestureで操作を受け取り、イメージビューを変形します。

class ViewController: UIViewController {
    @IBOutlet fileprivate weak var filterView: UIView!
    @IBOutlet fileprivate weak var imageView: UIImageView!

    lazy fileprivate var transformProperty = TransformProperty()

    override func viewDidLoad() {
        super.viewDidLoad()

        // Bring the expanded view to the forefront if needed
//        self.view.bringSubview(toFront: imageView)
    }

    @IBAction private func onPinchGesture(_ sender: UIPinchGestureRecognizer) {
        switch sender.state {
        case .changed:
            let scale = sender.scale
            if scale <= 1 {
                break
            }
            transformProperty.scale = (sender.scale - 1.0) * 0.5 + 1.0
            transform()
            // Darken the background color when scaled
            transformProperty.backgroundAlpha = (sender.scale - 1.0) * 0.8
            changeBaseViewBackgroundColor()
        case .ended, .cancelled:
            revertTransform()
        default:
            break
        }
    }

    @IBAction private func onPanGesture(_ sender: UIPanGestureRecognizer) {
        guard let view = sender.view else {
            return
        }
        switch sender.state {
        case .changed:
            transformProperty.point = sender.translation(in: view)
            transform()
            changeBaseViewBackgroundColor()
        case .ended, .cancelled:
            revertTransform()
        default:
            break
        }
    }
}

イメージビューの変形

transformPropertyの値に基づき、イメージビューを変形します。 指が離れて、操作が終わった時には、アニメーションを使って、元の位置にイメージビューを戻します。

fileprivate extension ViewController {
    func transform() {
        imageView.transform = CGAffineTransform(translationX: transformProperty.point.x, y: transformProperty.point.y)
            .scaledBy(x: transformProperty.scale, y: transformProperty.scale)
    }

    func revertTransform() {
        transformProperty = TransformProperty()
        UIView.animate(withDuration: 0.6, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 1.0, options: .curveEaseOut, animations: { () -> Void in
            self.imageView.transform = CGAffineTransform.identity
            self.transform()
            self.filterView.backgroundColor = UIColor.clear
        }, completion: nil)
    }

    func changeBaseViewBackgroundColor() {
        filterView.backgroundColor = UIColor(red: 0.0, green: 0.0, blue: 0.0, alpha: transformProperty.backgroundAlpha)
    }
}

UIGestureRecognizerDelegateの実装

trueを返すことにより、一つのジェスチャー中に他のジェスチャーも受け取ることができます。

extension ViewController: UIGestureRecognizerDelegate {
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

注意点

機能を実現するためには、イメージビューを最前面に表示し、その下にフィルタービューを表示する必要があります。 Interface Builderでのビューの重なりによっては、前面にビューを持ってくる必要があります。 また、イメージビューが入れ子構造になっている場合は、親要素ごと最前面に表示する必要があります。

self.view.bringSubview(toFront: imageView)

or

self.view.bringSubview(toFront: scrollView)
scrollView.bringSubview(toFront: stackView)
stackView.bringSubview(toFront: imageView)

サンプル

Scale-ImageView@githubに動作するプロジェクトがあります。

動作確認

このTipsは、「スマホの写真素材が売買できるサイトSnapmart」を開発する中で生まれました。 実際の動作をSnapmartアプリ(iOS)から確認できますので、是非ダウンロードしてみてください!

カメラロールのようなシンプルなイメージピッカーの作成

このエントリーをはてなブックマークに追加

カメラロールのようなシンプルなイメージピッカーの作成

概要

カメラロールのようなシンプルなイメージピッカーを作る方法について説明します。 CollectionViewを用意し、PHFetchResultで取得したPHAssetから画像を取得し、表示します。 また、iOS10より導入されたCollectionViewのprefetchを利用します。

screen_shot

ImagePickerViewControllerの説明

クラス定義

各種定数の定義と変数の初期化をします。kColumnCntやkCellSpacingの値を変えることで、CollectionViewの見た目を変更できます。 targetSizeはCollectionViewCellの大きさであり、後から読み込む画像サイズの大きさにもなります。(initView内で計算するため、CGSize.zeroを仮に代入する。)

class ImagePickerViewController: UIViewController {
    @IBOutlet fileprivate weak var collectionView: UICollectionView!

    fileprivate let kCellReuseIdentifier = "Cell"
    fileprivate let kColumnCnt: Int = 3
    fileprivate let kCellSpacing: CGFloat = 2
    fileprivate var fetchResult: PHFetchResult<PHAsset>!
    fileprivate var imageManager = PHCachingImageManager()
    fileprivate var targetSize = CGSize.zero

    override func viewDidLoad() {
        super.viewDidLoad()

        initView()
        loadPhotos()
    }
}

ビューの初期化と写真の読み込み

カラム数とマージンサイズ、CollectionViewのwidthよりtargetSizeを決定します。 また、loadPhotos内では、PHFetchOptionsにより写真を作成日時の降順に読み込みます。 なお、CollectionViewのdelegateとdataSourceへの紐づけは、Interface Builder上で行っています。

fileprivate extension ImagePickerViewController {
    fileprivate func initView() {
        let imgWidth = (collectionView.frame.width - (kCellSpacing * (CGFloat(kColumnCnt) - 1))) / CGFloat(kColumnCnt)
        targetSize = CGSize(width: imgWidth, height: imgWidth)

        let layout = UICollectionViewFlowLayout()
        layout.itemSize = targetSize
        layout.minimumInteritemSpacing = kCellSpacing
        layout.minimumLineSpacing = kCellSpacing
        collectionView.collectionViewLayout = layout

        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: kCellReuseIdentifier)
    }

    fileprivate func loadPhotos() {
        let options = PHFetchOptions()
        options.sortDescriptors = [
            NSSortDescriptor(key: "creationDate", ascending: false)
        ]
        fetchResult = PHAsset.fetchAssets(with: .image, options: options)
    }
}

UICollectionViewDataSourceの実装

UICollectionViewDataSourceを実装します。fetchResult.object(at: indexPath.item)により、PhotoAssetオブジェクトを取得し、その後requestImageによりUIImageを取得します。

extension ImagePickerViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: kCellReuseIdentifier, for: indexPath)
        let photoAsset = fetchResult.object(at: indexPath.item)
        imageManager.requestImage(for: photoAsset, targetSize: targetSize, contentMode: .aspectFill, options: nil) { (image, info) -> Void in
            let imageView = UIImageView(image: image)
            imageView.frame.size = cell.frame.size
            imageView.contentMode = .scaleAspectFill
            imageView.clipsToBounds = true
            cell.contentView.addSubview(imageView)
        }
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return fetchResult.count
    }
}

UICollectionViewDelegateの実装

UICollectionViewDelegateを実装し、Cellのサイズやセクション数を決定します。 また、didSelectItemAtで写真選択時の処理を記述します。

extension ImagePickerViewController: UICollectionViewDelegate {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }

    func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: IndexPath) -> CGSize {
        return targetSize
    }

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let photoAsset = fetchResult.object(at: indexPath.item)
        print(photoAsset.description)
    }
}

UICollectionViewDataSourcePrefetchingの実装

iOS10より追加されたUICollectionViewDataSourcePrefetchingを実装します。 prefetchItemsAtで写真をキャッシュし、cancelPrefetchingForItemsAtでキャッシュを開放します。

extension ImagePickerViewController: UICollectionViewDataSourcePrefetching {
    func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
        DispatchQueue.main.async {
            self.imageManager.startCachingImages(for: indexPaths.map{ self.fetchResult.object(at: $0.item) }, targetSize: self.targetSize, contentMode: .aspectFill, options: nil)
        }
    }

    func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
        DispatchQueue.main.async {
            self.imageManager.stopCachingImages(for: indexPaths.map{ self.fetchResult.object(at: $0.item) }, targetSize: self.targetSize, contentMode: .aspectFill, options: nil)
        }
    }
}

サンプル

Image-Picker@githubに動作するプロジェクトがあります。

動作確認

このTipsは、「スマホの写真素材が売買できるサイトSnapmart」を開発する中で生まれました。 実際の動作をSnapmartアプリ(iOS)から確認できますので、是非ダウンロードしてみてください!

2017年3月24日金曜日

Xcodeの新規作成時のファイルテンプレートをカスタマイズする

このエントリーをはてなブックマークに追加

概要

UIViewController等を新規作成する際に、不要なコードが入っている。また、チーム内のコーディングルールに則ったカスタムのテンプレートを利用したい。そこで、Xcodeインストール時に用意されたテンプレートをカスタマイズし、ファイルの新規作成時に読み込めるようにする。

インストール方法と使い方

1. シェルスクリプトとサンプルテンプレートの入手

Xcode-Template@github よりxcodeTemplateディレクトリにあるシェルスクリプトとサンプルテンプレートをダウンロードする。

2. テンプレートの配備

シェルスクリプトにより、カスタムテンプレートをXcodeで読み込めるようにする。Xcodeを開いている場合には、再起動する。

xcodeTemplate> chmod +x deploy_templates.sh
xcodeTemplate> ./deploy_templates.sh

3. テンプレートの選択

ファイルの新規作成より、[Custom]-[Cocoa Touch Class]を選択

select_custom

4. クラスの作成

カスタムしたCocoa Tocuhクラスのサブクラスとしてクラスを作成する。(例ではUIViewController)

new_file

5. テンプレートの適用を確認

custom_view_controller

テンプレートの修正方法

xcodeTemplate/Cocoa Touch Class.xctemplate/UIViewControllerSwift/___FILEBASENAME___.swiftを修正する。 また、修正後には、再度シェルスクリプトを起動する。

UIViewController以外を修正する場合には、/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/Xcode/Templates/File Templates/Source/Cocoa Touch Class.xctemplate にある修正したいクラス名のディレクトリをxcodeTemplate/Cocoa Touch Class.xctemplate以下にコピーし、修正する。

サンプル

Xcode-Template@githubに動作するプロジェクトがあります。

2017年3月23日木曜日

NSNotificationをシンプルに書く方法

このエントリーをはてなブックマークに追加

概要

NSNotificationをpostしたりaddObserver/removeObserverする際、NSNotification.Nameに文字列を指定するとタイプミスをすると、正しく動作しなくなってしまいます。しかし、ビルドは通るため、気づくのに時間がかかってしまいます。そこで、NSNotification.Nameを拡張し、独自のnameを定義します。

サンプル

Notification-Name@githubに動作するプロジェクトがあります。

2017年3月22日水曜日

Interface Builderとソースコードで共通のカラーパターンを利用する方法

このエントリーをはてなブックマークに追加

概要

iOSにおいて、定義したカラーパターンをInterface Builderからでもソースコードからでも利用する方法です。ソースコードからの利用には、R.swiftを利用します。また、Interface Builderからの利用には、ColorToolsにあるHtml2Clrを利用します。

用語説明

R.swiftとは?

AndroidにおけるRクラスのように、ViewやStoryboard, Stringsなどのリソースをソースコードから利用できるようにしたものです。Identifier等を文字列で使用する必要もなく、タイポによるバグを防ぎます。

Html2Clrとは?

定義したカラーパターンをclrファイルに変換します。clrファイルはMacで読み込むことのできるカラーパターン定義用のバイナリファイルです。

導入手順

1. R.swiftのインストール

R.swiftをインストールします。

2. Html2Clrのインストール

ColorToolsよりHtml2Clrをビルドし、プロジェクトのルートディレクトリ等に実行ファイルを置きます。もしくは、Color-Palette-Pattern@githubより導入してください。

3. カラーパターンの定義

1行につき、16進数RGB 名前の形でカラーパターンを定義し、Xcodeプロジェクトにインポートします。

color

4. R.swift用Run Scriptの定義

ターゲットの[Build Phases]-[左上の+]-[New Run Script Phase] より、ビルド時にR.swiftを起動するようにします。

color

下記はCocoaPodsを利用してR.swiftをインストールした場合のパスになります。パスは実行環境に合わせて変更してください。また、このRun Scriptは必ずCompile Sourcesよりも上に定義する(先に実行する)必要があります。

"$PODS_ROOT/R.swift/rswift" "$SRCROOT/$PROJECT_NAME"

5. clr出力用Run Scriptの定義

4で定義したRun Scriptよりも一つ上に(先に)カラーパターン変化用のRun Scriptを定義します。3で用意したカラーパターンファイルからclrファイルを出力し、~/Library/Colorsにコピーします。Html2Clr等のパスは環境に合わせて書き換えてください。

color

FILE_NAME="mytheme"
# Generate color palette file
"./Html2Clr" "$SRCROOT/$PROJECT_NAME/$FILE_NAME.txt"
# Copy to user's color palette directory
"cp" "$SRCROOT/$PROJECT_NAME/$FILE_NAME.clr" "$HOME/Library/Colors/"

6. ビルド

プロジェクトを一度ビルドし、clrファイルR.generated.swiftを出力します。

7. ファイルのインポート

出力されたclrファイルとR.generated.swiftをプロジェクトにインポートします。

color

8. ビルド

再度ビルドします。これにより、clrファイルを読み取り、R.generated.swiftにcolorの定義が自動生成されます。

9. カラーパターンの編集→ビルド→Xcode再起動

カラーパターンを編集した際には、ビルドをし、その都度Xcodeを再起動する必要があります。これは、Xcodeの起動時に、~/Library/Colorsディレクトリの中にあるclrファイルを読み込むためです。

使い方

ソースコードから使う

R.color.{clrファイル名}の中で定義されています。

color

Interface Builderから使う

Colorsより作成したテーマを選ぶと利用できます。

color

デメリット

ソースコードとInterface Builderから共通したカラーパターンを利用できるようになりましたが、結局のところInterface Builderでは変数のように利用できないため、カラーパターンを変更した場合には、再度Interface Builderから色を設定する必要があります。動的にアプリのカラーテーマを変えたい場合には、やはりソースコードから修正する必要があります。

サンプル

Color-Palette-Pattern@githubに動作するプロジェクトがあります。