フリープログラマー日記

iOS,アンドロイド開発を気ままにしながら生きてるおじさんのブログです。

第28回 アプリ設定ファイル

 アプリ作る時って、やっぱりアプリ設定ファイルは作るんですよね?
いい方法があるのかもしれないですが、私の場合は、settings クラスを作って、そこにアクセスして作っています。これが簡単な気がする。
 なぜ、こんな話になっているとというと、とってもまずい状態になっていて、コードの中にいろいろな定数(例えば、ビューの幅や、色の値や、その他諸々・・・)を埋め込んでしまっているので、変更しきれなくなっているのです。(泣)
 これは先に考えておくべきでした。改造に取り掛かります。

次の図は、現在のxcodeとAndroidStudioのファイル構成です。
AndroidStudioの方は強制的にアルファベット順なので、整合していません。しかし、基本的には同じ構成になっています。違いは2〜3箇所くらいは出てきますが。このまま進めると大変そうなので、Settingsクラスを作ってみようと思っています。
こんなことしてるので、アプリ作りはなかなか進みません。

f:id:momonga117:20180526164539p:plain

うーむ、Runnerから始まるクラスが多すぎる・・・

第27回 Androidスクロールビューでタッチイベント

スクロールビューに名前の入ったビューアイテムが乗っています。スクロールビューのタッチイベントは嫌なので、このビューアイテムにタッチイベントをセットすることにしました。

私は、アマプログラマーなので、やれることだけしかしません。できるだけ難しいことは避ける。厳しいルートは通らない軟弱な登山家みたいなものです。

さて、タッチイベントですが、今回はAndroid版だけです。


RunnerEntryView.java(抜粋)

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int touch = event.getAction();      // タッチの種類を格納する
        float posX = event.getX();            // 位置を保存する
        float posY = event.getY();

        switch (touch) {
            case MotionEvent.ACTION_DOWN:
                // code
                break;
            case MotionEvent.ACTION_MOVE:
                // code
                break;
            case MotionEvent.ACTION_UP:
                if (entry) {
                    entry = false;
                    entryCancel.run();  // Runnerクラスへのアクセス
                } else {
                    entry = true;
                    entryOk.run();  // Runnerクラスへのアクセス
                }

                invalidate();       // このビューの再描画


                break;
            case MotionEvent.ACTION_CANCEL:
                // cede
                break;
            default:
                break;
        }
        return true;
    }

タッチアップが発生すると、内部のフラグentryを確認し、true、falseを切り替えています。それだけです。簡単ですね。

ただ、後で、利用するため、runnerオブジェクトに状態を通知します。
そのためにランナブルがついています。

もう一つ、ユーザーに状態を示すために、invalidate()が。

これは、Viewに対して、再描画と指示するものです。(と理解するのが単純でいいのです。直訳では「無効にする」なんですが。)

で、invalidate()で動作するのが、drawメソッドなんですね。

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        if (entry) {
            setBackgroundColor(Color.argb(255,255,255,30));
        } else {
            setBackgroundColor(Color.argb(255,200,200,200));
        }

        final float numberWidth = myWidth * 0.22f;

        // アンドロイドではcanvasを使用する,canvasは呼び出し元にあるので引数で受け取る

        // android では Paintクラスを使って色やスタイルを決めていく
        Paint paint = new Paint();

        // 両サイド
        paint.setStrokeWidth(lineWidth);
        paint.setColor(Color.BLACK);
        canvas.drawLine(lineWidth/2, 0, lineWidth/2, myHeight,paint);
        canvas.drawLine(myWidth-lineWidth/2, 0, myWidth-lineWidth/2, myHeight,paint);

        // 上下サイド
        paint.reset();
        paint.setColor(Color.BLACK);
        paint.setStrokeWidth(lineWidth*0.3f);
        canvas.drawLine(0,lineWidth*0.15f,myWidth,lineWidth*0.15f,paint);
        canvas.drawLine(0,myHeight-lineWidth*0.15f,myWidth,myHeight-lineWidth*0.15f,paint);
(以下略)

entryの状態を見て、背景色を変えています。

タップすると、色が変わります。

これはライティングのゲームには使えそうですね。
いい感じで反応してくれます。

f:id:momonga117:20180526093704p:plain

第26回 氏名の入出力!

ちょっと苦戦します。スクロールビューのタッチイベントの扱いって難しいですね。

話は、少し前になりますが、Androidのスクロールビューは単一のレーアウトにまとめてのせるしか方法がなかったので、iOSとは同じ書き方ができませんでした。Androidの方にひとつ余分にクラスを作る必要があります。

この余分なレイアウトクラスに働きを持たせると整合性がなくなっていくので、このレイアウトにはあまり仕事をさせたくありません。

ということは、iOSではスクロールビューに置いたRunnerオブジェクト、Androidではレイアウトクラスに置いたRunnerオブジェクトにエントリしたよ〜みたいな挙動をさせるのが一番かなって思います。

ただ、オブジェクト本体はTeamクラスに所属しているメンバーで、ビューに乗っているのは、Runnerクラスから出てきたオブジェクトなので、元のオブジェクトのEntryフラグをいじる必要がある。???

とても複雑ですよね。どうやっても簡単には説明できない・・・

これはクロージャの出番になりそうです。う〜ん、頭が痛い。

昨日から、メンバー登録の部分を作っています。
関数だけ。

Swift版です。Team.swiftに置いてます。

    // 選手の追加・削除
    func addRunner(name: String) {
    
        // 新しいrunnerオブジェクトを作り、氏名を保存する
        lastId += 1
        saveLastId()
        let runner: Runner = Runner(number: lastId)
        runner.saveName(name: name)
        runners.append(runner)
        
    }
||< 

java版です。Team.javaに置いてます。
>|java|
   // 選手の追加・削除
    void addRunner(String name) {

        // 新しいrunnerオブジェクトを作り、氏名を保存する
        lastId++;
        saveLastId();
        final Runner runner = new Runner(lastId,myContext);
        runner.saveName(name);
        runners.add(runner);

    }

かなり、似たプログラムになっています。

途中、出てくるsaveLastIdという関数は、今ここまでのIDを使ってますよと記録しておき、次にリストを作るときは、ファイルからその番号までを読み込むことになります。

この関数をテストしてみました。

f:id:momonga117:20180526005039p:plain

スペーシングがいまいちですね〜。センスのなさが・・・悲しい。
名前をもう少し左に寄せて、右側にエントリーボタンを作ってみたいと思っています。

第25回 クラスの設計

この連載を読んでいただき、ありがとうございます。

「大発見やー」と自画自賛していることを書いてみたいと思いますが、もしかしたらそんなのは当たり前のことなのかもしれません。プロのプログラマーは隠してるんじゃないの?みたいに思えてきますが。

まず、私の設計方針である「iOSAndroidで同じクラス設計をする」というところから振り返りますが、図式化すると次のようになります。

f:id:momonga117:20180525133314p:plain

最初のクラスはアプリの管理クラスですね。
続いて呼ばれるのが、画面の管理クラス。
実装はいろいろあると思いますし、ここでstoryboard,XMLファイルが登場するのが教科書では多いですよね。

で、私の場合は、この画面を提供するクラスから、画面表示パーツを呼び出しています。

f:id:momonga117:20180525133754p:plain

一つのビューを作ってそれを表示するだけの教科書的な説明では、この形でも十分だとは思うのですが。本屋さんにある解説書はほとんどそうですよね。
今まで、これが自然な流れだと思っていたし、なんの疑いも持たなかったのですが、このままだと次のようなことが起こってきます。

Teamクラスを作って、それをEntryViewを作るとき渡す。終われば返してもらう。
Runningクラスを作るとき、渡す。終われば返してもらう。
渡したとき、Teamクラスの中身をいじるのが普通ですよね?
エントリーされたら、フラグを立てたり、新規のメンバーを追加したり、ラップデータを記録したり。

Teamクラスが移動していくのはとても不合理です。だいたい、うまく返るのかどうかも怪しいです。

Teamクラスが動くのがいけないんだと考えたとき、閃いたのが、「Team クラスを中心にしよう。」だったわけです。コペルニクス的転回ですよね。かつて、天動説に取って代わって地動説が合理的と判断されたのと同じです。

さて、クラス設計は次のように変わりました。

f:id:momonga117:20180525135442p:plain

あるメニュー表示するとき、Teamクラスに「このメニューを表示するよ」と伝わります。すると、Teamクラスは、下位のビューを作るクラスに指示を出し、必要な名前を渡して、ビューを返してもらいます。それを画面を表示するクラスに返して、表示するという寸法です。

このような実装に変わっています。(java版)

呼び出し側(抜粋)

    public void showEntryMenu() {

        // 以前のビューは消しておく
        this.removeAllViews();
        // 端末のサイズを取得
        final int width = this.getWidth();
        final int height = this.getHeight();

        // Entry のインスタンスを作成。
        EntryView entryView = myTeam.entryMenuView(width,height);
        
        entryView.setBackToMenu(new Runnable() {
            @Override
            public void run() {
                showMainMenu();
            }
        });

        // Androidではレイアウトパラメーターを使用
        LayoutParams mainMenuParams = new LayoutParams(
                width,height
        );
        mainMenuParams.addRule(CENTER_IN_PARENT);
        mainMenuParams.addRule(CENTER_IN_PARENT);
        // ビューを画面中央に表示
        addView(entryView, mainMenuParams);

    }

10行目のところがTeam.entryMenuView(width,height); という形に変わっています。Teamクラスに作るように指示を出しているわけです。


Teamクラス(抜粋)

public EntryView entryMenuView(int width, int height) {

        ScrollView scrollView = new ScrollView(myContext);
        RunnerList listView = new RunnerList(myContext,width*8/10,width*13/100);
        // 選手名と番号を入れるメソッド
        for (int i=0; i<runners.size(); i++) {
            listView.setRunnerView(i+1,runners.get(i).getName());
        }

        scrollView.addView(listView);

        return new EntryView(myContext, width,height,scrollView);
    }

Teamクラスでは
RunnerListを作った後、氏名を送って、氏名入りのビューを完成させます。

これで、TeamオブジェクトやRunnerオブジェクトをコピーしたりするややこしいことはしなくて良くなって、一安心。

管理すべきデータを中心に考えるというのは多分当たり前?

それなら、今までやってたのはなんだったんだろう?

第24回 設計変更。クラスって難しい。

前回、Runner クラスが便利そうだと書きましたが、進めているうちに、Runner クラスを統括していく、Team クラスがあるといいなと思いつき(私は常に思いつきでプログラムする・・・)、Team クラスを書いてみました。

Androidの記録方法、SharedPreferencesあたりを思い出すのに少しかかりましたが、ここに全て保存しようと思います。容量はそんなに大きくないです。

あと、Team クラスをView に渡そうと考えてましたが、これって逆ですよね。

Team クラスのアイテムとして、エントリービューや記録などがあるとスッキリするので、 Team クラスにViewを返すメソッドを実装することにしました。.
最後の方にある EntryView を返すメソッドがそれです。

これで、EntryView の”未登録”部分をタップした時、入力モードに移るようにすればなかなかいい感じなのではないかと思います。

import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import android.widget.ScrollView;
import java.util.ArrayList;
public class Team {

    
    private Context myContext;
    private int lastId;      // 登録メンバー数

    private ArrayList<Runner> runners;

    // コンストラクタ
    Team(Context context) {

        myContext = context;
        lastId = loadLastId();
        runners = new ArrayList<>();

        // count(=登録数)が 0 の時は、名前の読み込みはしない
        if (lastId>0) {
            for (int i=1 ; i<=lastId ; i++ ) {
                runners.add(new Runner(i,myContext));
            }
        }
        // 未登録枠を一つ作る
        runners.add(new Runner(lastId+1,myContext));
    }

    private int loadLastId() {

        // ID番号は1からの連番で最終番号をプリファレンスに保存しておく
        // この関数は最終番号を読み出す

        // アプリ標準の Preferences を取得する
        SharedPreferences sp =
                PreferenceManager.getDefaultSharedPreferences(myContext);

        return sp.getInt("LastID",0);
    }

    private void saveLastId(int presentId) {

        // ID番号は1からの連番で最終番号をプリファレンスに保存しておく
        // この関数は最終番号を書き出す
        SharedPreferences sp =
                PreferenceManager.getDefaultSharedPreferences(myContext);

        // Preferences に書き込むための Editor クラスを取得する
        SharedPreferences.Editor editor = sp.edit();
        editor.putInt("LastID",presentId);
        editor.apply();
    }

    // 選手の追加・削除
    void addRunner(String name) {

        // 氏名未登録のrunnerオブジェクトに氏名を入れて保存する
        runners.get(runners.size()).saveName(name);
        lastId++;
        saveLastId(lastId);

        // 未登録枠を一つ作る
        runners.add(new Runner(lastId+1,myContext));
    }

    public EntryView entryMenuView(int width, int height) {

        ScrollView scrollView = new ScrollView(myContext);
        RunnerList listView = new RunnerList(myContext,width*8/10,width*13/100);
        // 選手名と番号を入れるメソッド
        for (int i=0; i<runners.size(); i++) {
            listView.setRunnerView(i+1,runners.get(i).getName());
        }

        scrollView.addView(listView);

        return new EntryView(myContext, width,height,scrollView);
    }
}


EntryView はこのようになっています。

import android.content.Context;
import android.graphics.Color;
import android.graphics.Typeface;
import android.util.TypedValue;
import android.widget.RelativeLayout;
import android.widget.ScrollView;
import android.widget.TextView;


public class EntryView extends RelativeLayout {


    public EntryView(Context context,int width, int height, ScrollView runnerEntryList) {

        // ランナーを指定する画面。
        super(context);
        setBackgroundColor(Color.argb(255,100,240,255));
        // このビューのサイズを受け取っておく
        // エントリー用のパーツを配置する
        // Entry(走者名の登録) TextView
        TextView title = new TextView(getContext());
        title.setText("Entry(走者名の登録)");
        final float size = width * 0.07f;
        title.setTextSize(TypedValue.COMPLEX_UNIT_PX,size);
        title.setTextColor(Color.BLACK);
        title.setTypeface(Typeface.DEFAULT_BOLD);
        LayoutParams titleParams = new LayoutParams(
                LayoutParams.WRAP_CONTENT,
                LayoutParams.WRAP_CONTENT);
        titleParams.addRule(ALIGN_PARENT_TOP);
        titleParams.addRule(CENTER_HORIZONTAL);

        this.addView(title,titleParams);

        // UIButton 2個、「メニューに戻る」「奏者選択終了」

        // ボタンビューの位置決めをしたくないので、1個のViewに乗せてしまいます。

        // ビューのサイズは下から15%
        EntryButtons buttons = new EntryButtons(getContext(),width,width*15/100);
        RelativeLayout.LayoutParams buttonsParams = new LayoutParams(
                width,width*15/100
        );
        buttonsParams.addRule(RelativeLayout.CENTER_IN_PARENT);
        buttonsParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);

        addView(buttons,buttonsParams);

        // スクロールビューの設置
        RelativeLayout.LayoutParams SCViewParams = new LayoutParams(
                width*80/100,height-width*40/100
        );

        SCViewParams.addRule(CENTER_IN_PARENT);
        SCViewParams.addRule(CENTER_IN_PARENT);

        addView(runnerEntryList,SCViewParams);
    }
}

第23回 クラスの設計は難しいです!

スクロールビューまで来て、はたと困りました。

なぜなら、思いつくままにプログラムを書く癖があるので、後になってここはどうしようかと考えてしまうから。

で、困った原因はというと、Runnerクラスってこのアプリの中心的存在なのに、いちいち消したり、ファイルから呼び出したりするもの?って疑問に思えたからなんです。
最初にこのクラスをListにしておいて、エントリーするときや、計測するときはこのリストを渡してしまえばいいんじゃないかって思ったんですよね。

だいたい、メニューを行き来する間に消えてしまうクラスにデータを保持させておくのは効率が悪いですよね?

なので、スタティックなRunnerクラスを束ねたRunnerListを作るのがいいんじゃないかと思い、作ってみようかと思います。

まずいことに、スクロールビューに並べているオブジェクトに Runner と名前をつけているので、まず、これを RunnerEntryView とRenameしておきます。

Refacter - Rename どちらにもありますね。まあ、あるのが普通なんでしょうけど。


さて、こんなクラスのイメージですが・・・

import UIKit

class Runner {
    
    let myNumber: Int
    private var myName: String
    var entry: Bool = false
    var lap: Int = 0
    private var time: [Int]     // タイムは100倍して切り捨て
    
    
    init(number: Int) {
        
        myNumber = number
        myName = ""
        time = []
        
    }
    
    // 名前セッター・ゲッター
    func setName(name: String) {
        myName = name
    }
    
    func getName() -> String {
        return myName
    }
    
    
    // ラップデータの処理
    func setLapTime(rapTime: Float) {
        // 100倍して整数化
        let time100 = Int(rapTime*100)
        time.append(time100)
        lap += 1    // ラップ数をカウント
    }
    
    // ラップボタン
    func LapButton(frame: CGRect) -> UIButton {
        
        let lapButton: UIButton = UIButton(frame: frame)
        lapButton.setTitle(myName, for: .normal)
        
        return lapButton
    }
    
    // エントリー用のビュー
    func EntryView(frame: CGRect) -> RunnerEntryView {

        let entryView: RunnerEntryView = RunnerEntryView(frame: frame, number: myNumber)
        
        return entryView
        
    }

    
}

最後にある関数はスクロールビューに乗っけたものを、このクラスのメンバーとして返すように作り直そうと思っている物です。こうしておくと、名前も入れやすいし、便利なのではないかと。

もう一つのラップボタンの処理は、タイムを記録するのもこの Runner クラスなのかな?と思いながら、とりあえず UIButton を返す関数として作っています。
このボタンの挙動はこのクラスに書くべきなんでしょう。きっと。

第22回 Androidでスクロールビュー

ちょっと、イベントがあり、留守にしてました。
早速ですが、続きで、スクロールビューの設置。

Android版で注意するべきことが一つあり、スクロールビューにのせることができるのは一つのオブジェクトになっているということです。なので、中に入れるレイアウトを、リラティブレイアウト を利用することにしました。個人的な趣味により、リニアレイアウトは使いません。

では、まず、一人分の Runnerクラスから。

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.view.View;

public class Runner extends View {

    int myWidth;
    int myHeight;

    final float lineWidth;
    final int myNumber;

    String name = "未登録";        //氏名表示
    Boolean entry = false;         // エントリーしたか?


    public Runner(Context context, int number, int width, int height) {
        super(context);
        myNumber = number;
        myWidth = width;
        myHeight = height;

        lineWidth = width * 0.01f;

        setBackgroundColor(Color.argb(255,255,255,180));

    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        final float numberWidth = myWidth * 0.22f;

        // アンドロイドではcanvasを使用する,canvasは呼び出し元にあるので引数で受け取る

        // android では Paintクラスを使って色やスタイルを決めていく
        Paint paint = new Paint();

        // 両サイド
        paint.setStrokeWidth(lineWidth);
        paint.setColor(Color.BLACK);
        canvas.drawLine(lineWidth/2, 0, lineWidth/2, myHeight,paint);
        canvas.drawLine(myWidth-lineWidth/2, 0, myWidth-lineWidth/2, myHeight,paint);

        // 上下サイド
        paint.reset();
        paint.setColor(Color.BLACK);
        paint.setStrokeWidth(lineWidth*0.3f);
        canvas.drawLine(0,lineWidth*0.15f,myWidth,lineWidth*0.15f,paint);
        canvas.drawLine(0,myHeight-lineWidth*0.15f,myWidth,myHeight-lineWidth*0.15f,paint);

        // 見た目が悪いので、最初の分だけ上の線を太く。
        if (myNumber == 1) {
            paint.reset();
            paint.setStrokeWidth(lineWidth*0.8f);
            paint.setColor(Color.BLACK);
            canvas.drawLine(0,lineWidth*0.4f,myWidth,lineWidth*0.4f,paint);
        }

        // 中間の仕切り線
        paint.reset();
        paint.setColor(Color.BLACK);
        paint.setStrokeWidth(lineWidth*0.6f);
        canvas.drawLine(numberWidth,0.0f,numberWidth,myHeight,paint);

        // 番号の描画
        paint.reset();
        if (name=="未登録") {
            paint.setColor(Color.LTGRAY);
        } else {
            paint.setColor(Color.BLACK);
        }
        paint.setTextSize(myHeight*0.5f);
        paint.setTextAlign(Paint.Align.CENTER);
        final String number = String.valueOf(myNumber);
        canvas.drawText(number,numberWidth*0.5f,myHeight*0.7f,paint);

        paint.setTextSize(myHeight*0.6f);
        paint.setTextAlign(Paint.Align.CENTER);
        canvas.drawText(name,numberWidth*0.5f+myWidth*0.5f,myHeight*0.7f,paint);

    }
}

途中で、entryというBooleanの変数が入っていますが、これはまだ使っていません。いずれ使うことになるだろうなと思いながら書いています。

だいたい、オブジェクト指向プログラムというのは、オブジェクトにその属性となる変数(プロパティ)や関数(メソッド)を入れるものだから、このRunnerオブジェクトというのは、名前、タイム、周回数、タイム、エントリーの状況、ボタンの位置など様々なプロパティを持つことが予想されます。ただ、心配な状況として、エントリーが終わると廃棄されるのか、そもそも、Viewオブジェクトでいいのか?みたいなちょっと心配な状況があります。

この辺りが、教科書にない自分で考えなければならないところなんですよね。

さて、続いて、スクロールビューのクラスですが、インナークラスとしてリラティブレイアウト を入れました。おかしいかもしれませんが、スクロールビューのコードが少ないのでいい感じです。

import android.content.Context;
import android.widget.RelativeLayout;
import android.widget.ScrollView;

public class RunnerList extends ScrollView {

    /**
     * このクラスはスクロールビューの中におくものとして作成
     * 内部クラスとして一つのリラティブレイアウト を作成し
     * スクロールビューにのせる
     */

    // 一人分のサイズ
    private int aWidth;
    private int aHeight;

    // このクラスには選手名のリストが必要

    // 暫定的に50人いるとして、ビューを作成
    private int member = 50;


    // コンストラクタ
    public RunnerList(Context context,int width, int height) {
        super(context);

        aWidth = width;
        aHeight = height;

        RunnerLayout runnerLayout = new RunnerLayout(getContext());

        addView(runnerLayout);
    }


    class RunnerLayout extends RelativeLayout {


        // コンストラクタ
        public RunnerLayout(Context context) {
            super(context);

            for (int i=1; i<=member; i++) {

                final Runner runner = new Runner(getContext(),i,aWidth,aHeight);
                runner.setId(i);        // ビューにidセット

                // Androidの位置決めはレイアウトパラメータで指定する
                RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
                        aWidth,aHeight);

                if (i == 1) {
                    // 1番目のビューは親ビューを基準にする
                    params.addRule(RelativeLayout.CENTER_IN_PARENT);
                    params.addRule(RelativeLayout.ALIGN_PARENT_TOP);
                } else {
                    // 2番目以降は前のビューを基準にする
                    params.addRule(RelativeLayout.CENTER_IN_PARENT);
                    params.addRule(RelativeLayout.BELOW,i - 1);
                }

                addView(runner,params);

            }
        }
    }
}

Runnerビューを縦にずらずらっと並べるのですが、idをセットし、一つ前のビューの真下に順番に置いています。多分これがいいやり方だと思うのですが。
なお、1番目だけは、親ビューを基準にしています。

そして、スクロールビューでは Addview するだけで終わっています。

このクラスの呼び出しは、前に作ったEntryViewにおきます。(ボタン配置の後)

        addView(buttons,buttonsParams);

        // スクロールビュー設置
        // 一人分のサイズ
        final int aWidth = myWidth*80/100;
        final int aHeight = myWidth*13/100;

        final RunnerList runnerList = new RunnerList(getContext(),aWidth,aHeight);

        RelativeLayout.LayoutParams runnerListParams = new RelativeLayout.LayoutParams(
                myWidth*80/100, myHeight-myWidth*40/100
        );
        runnerListParams.addRule(CENTER_IN_PARENT);
        runnerListParams.addRule(CENTER_IN_PARENT);

        addView(runnerList,runnerListParams);
    }
}

もう一つ悩みが・・・
int と float が混ざりすぎてて厄介ですよね?
ちょっと課題です。

結果としてはほぼ同じ状態となりました。

f:id:momonga117:20180522111553p:plain

第21回 swiftでスクロールビュー

 スクロールビューって、利用しない手はないのに、なかなかいいサンプルにお目にかかりません。ぜひぜひ参考にしてください。結構、教科書的に書けたように思います。

プログラムは3つの部分に分かれます。
A:スクロールビューを呼び出すプログラム
B:スクロールビュー本体
C:スクロールビューに配置するオブジェクト

f:id:momonga117:20180518101018p:plain

xcodeの階層表示ですが、クリーム色の小さな長方形がCです。

色々付け加えてますが、まず、CのオブジェクトをRunnerクラスとして定義しました。

import UIKit

class Runner: UIView {
    
    let thiswidth: CGFloat
    let thisheight: CGFloat
    
    let lineWidth: CGFloat
    let myNumber: Int
    
    var name: String = "未登録"        //氏名表示
    var entry: Bool = false         // エントリーしたか?
    
    
    init(frame: CGRect, number: Int) {
        // 自前のイニシャライザ
        thiswidth = frame.width
        thisheight = frame.height
        
        lineWidth = frame.width*0.01
        myNumber = number
        super.init(frame: frame)
        // 背景色をセット、エントリー時には色を変える 色は未調整
        if entry {
            backgroundColor = UIColor(red: 255/255, green: 255/255, blue: 240/255, alpha: 255/255)
        } else {
            backgroundColor = UIColor(red: 255/255, green: 255/255, blue: 180/255, alpha: 255/255)
        }
        
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func draw(_ rect: CGRect) {
        // いつものビューへの描画

        // 背景色をセット、エントリー時には色を変える 色は未調整
        if entry {
            backgroundColor = UIColor(red: 255/255, green: 255/255, blue: 240/255, alpha: 255/255)
        } else {
            backgroundColor = UIColor(red: 255/255, green: 255/255, blue: 180/255, alpha: 255/255)
        }

        // 番号枠,氏名枠の幅計算
        let numberWidth = thiswidth * 0.22
        let nameWidth = thiswidth - numberWidth - lineWidth * 3
        
        // コンテキストの取得
        let context = UIGraphicsGetCurrentContext()
    
        // 両サイド
        context?.setLineWidth(lineWidth)
        context?.move(to: CGPoint(x: lineWidth/2, y: 0))
        context?.addLine(to: CGPoint(x: lineWidth/2, y: thisheight))
        context?.move(to: CGPoint(x: thiswidth-lineWidth/2, y: 0))
        context?.addLine(to: CGPoint(x: thiswidth-lineWidth/2, y: thisheight))
        context?.strokePath()
        
        // 上下サイド
        context?.setLineWidth(lineWidth*0.3)
        context?.move(to: CGPoint(x: 0, y: lineWidth*0.15))
        context?.addLine(to: CGPoint(x: thiswidth, y: lineWidth*0.15))
        context?.move(to: CGPoint(x: 0, y: thisheight - lineWidth*0.15))
        context?.addLine(to: CGPoint(x: thiswidth, y: thisheight - lineWidth*0.15))
        context?.strokePath()
        
        // 見た目が悪いので、最初の分だけ上の線を太く。
        if myNumber == 1 {
            context?.setLineWidth(lineWidth*0.8)
            context?.move(to: CGPoint(x: 0, y: lineWidth*0.4))
            context?.addLine(to: CGPoint(x: thiswidth, y: lineWidth*0.4))
        }
        
        // 中間の仕切り線
        context?.setLineWidth(lineWidth*0.6)
        context?.move(to: CGPoint(x: numberWidth, y: 0))
        context?.addLine(to: CGPoint(x: numberWidth, y: thisheight))
        context?.strokePath()
        
        
        // 番号の描画
        let textcolor = name=="未登録" ? UIColor.lightGray : UIColor.black
        let numCenter: CGPoint = CGPoint(x:numberWidth*0.5, y: thisheight*0.5)
        centerString(string: String(myNumber), charSize: thisheight*0.5, center: numCenter, color: textcolor)
        
        // 氏名の描画
        let nameCenter: CGPoint = CGPoint(x: numberWidth + lineWidth + nameWidth * 0.5, y: thisheight*0.5)
        centerString(string: name, charSize: thisheight*0.6, center: nameCenter, color: textcolor)
        
        
        
    }
    
    func centerString(string: String, charSize: CGFloat, center: CGPoint, color: UIColor) {
        // 文字列、文字サイズ、中央を示す座標、
        let stringAttributes: [NSAttributedStringKey : Any] = [
            NSAttributedStringKey.foregroundColor : color,
            NSAttributedStringKey.font : UIFont.systemFont(ofSize: charSize)
        ]
        
        let nsstr = string as NSString
        
        // サイズを取得
        let textSize: CGSize = nsstr.size(withAttributes: stringAttributes)
        let textTopLeft = CGPoint(x: center.x - textSize.width/2, y: center.y - textSize.height/2)
        
        // 文字列の描画
        nsstr.draw(in: CGRect(origin: textTopLeft, size: textSize), withAttributes: stringAttributes)
    }
}

体裁にこだわったため、少し長くなりました。
でも、これができたら、あとは楽です。

スクロールビューは継承して作っています。

import UIKit

class RunnerList: UIScrollView {
    
    private var aWidth: CGFloat
    private var aHeight: CGFloat
    
    
    // このクラスには選手名のリストが必要
    
    // 暫定的に50人いるとして、ビューを作成
    
    var member: Int = 50
    
    override init(frame: CGRect) {
        // 一人分のサイズをもらっておく
        aWidth = 0.0
        aHeight = 0.0
        
        
        super.init(frame: frame)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    init(frame: CGRect, unitWidth: CGFloat, unitHeight: CGFloat) {
        // 自前のイニシャライザ
        // 一人分のサイズをもらっておく
        aWidth = unitWidth
        aHeight = unitHeight
        super.init(frame: frame)
        
        // ここで走者リストの確認(後で実装)
        // メンバー数だけRunner(UIView)を作成
        // 初期状態では50番まで氏名がなくとも枠を作成する。
        
        self.contentSize = CGSize(width: unitWidth, height: unitHeight*50)
        
        
        for i: Int in 1...50 {
            
            let myRect = CGRect(x: 0.0, y: unitHeight * CGFloat(i-1), width: unitWidth, height: unitHeight)
            let unit: Runner = Runner(frame: myRect, number: i)
            addSubview(unit)
        }
    }
}

50個のRunnerオブジェクトを乗せているのがわかると思います。でも、大事なのは、self.contentSizeのところで、ここで、スクロールするかどうか決まります。指定するのを忘れないでください。

なお、一人分のサイズは呼び出し側よりもらっています。

呼び出し側はこんな感じです。

        let listFrame: CGRect = CGRect(x: myWidth * 0.1, y: myWidth * 0.2, width: myWidth * 0.8, height: myHeight - myWidth * 0.4)
        
        let list: RunnerList = RunnerList(frame: listFrame, unitWidth: myWidth * 0.8, unitHeight: myWidth * 0.13)
        
        addSubview(list)

単純ですが、frameの数値を変えたり、unitWidth,unitheightの数値を変えると、見栄えがすごく変わってしまいます。


完成した画面がこちら。

f:id:momonga117:20180518103409p:plain

スクロールしているところはお見せできませんが、実機でやってみてもツルンとスクロールしてくれて、気持ちいいですよ。

ただ、ここまでは、そんなに大変じゃないんですが、スクロールビューをタッチして、選択したり、名前登録に入ったり、まだまだ作り込まないといけません。そして、こういうところが、結構情報不足だったりするんですよね。

多分、このアプリで一番大変なのはこの辺りではないかと思っています。

ご参考になれば、幸いです。

第20回 setMarginがどこかに消えた!

ボタンを貼り付けました。
と言っても、見た目だけです。
なぜかというと、以前はあったはずのsetMarginが出てこない。Viewを生成し、setMerginを使おうとしても出てこない・・・謎。
今回、左ボタンは、左側のマージンを指定、右ボタンは、右側のマージン指定と考えていたのですが、この方法でボタンを配置するのは諦めました。

この謎は未だ解決していません。今までマージンを使ってやったことがないので、本当のところは良くわかりません。XMLで指定したら楽なのかもしれませんが。

そこで、2つの解決策を考えてみました。
ひとつ目は以前よく使ってた方法で、見えないViewを基準に配置し、その両側に、ボタンを配置する方法。
もう一つは、ひとつのビューを設置し、座標を計算して、ボタンに見えるように配置する方法。
できてしまえば、ボタンを貼り付けても、ビューにボタンを描いても中身は変わらないのですが。

f:id:momonga117:20180517192242p:plain

なんだかんだと考えて、2番目の方法を採用してみました。理由の一つは、プログラム上、一つのクラスにまとめることができたので、処理が書きやすくなったかなと思っています。

f:id:momonga117:20180517185206p:plain

出来上がったのがこちら。
体裁はだいたい同じになりました。
プログラムですが、呼び出す側がこちら。

        // ビューのサイズは下から横幅の15%
        EntryButtons buttons = new EntryButtons(getContext(),myWidth,myWidth*15/100);
        RelativeLayout.LayoutParams buttonsParams = new LayoutParams(
                myWidth,myWidth*15/100
        );
        buttonsParams.addRule(RelativeLayout.CENTER_IN_PARENT);
        buttonsParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);

        addView(buttons,buttonsParams);

そして呼び出される側として新しいクラスを導入しました。

public class EntryButtons extends View {

    // エントリー画面の下部の2個のボタンを作る
    private String button1Text = "メニューに戻る";
    private String button2Text = "走者選択終了";

    // このビューのサイズ
    private final int myWidth;
    private final int myHeight;


    public EntryButtons(Context context, int width, int height) {
        super(context);
        myWidth = width;
        myHeight = height;
    }

    public void firstButton() {
        button1Text = "メニューに戻る";
        button2Text = "走者選択終了";
        invalidate();
    }

    public void secondButton() {
        button1Text = "走者選択へ";
        button2Text = "決定する";
        invalidate();
    }

    @Override
    public void draw(Canvas canvas) {

        super.draw(canvas);
        Paint paint = new Paint();

        // グリーンの長方形
        paint.setColor(Color.argb(255,50,150,50));

        // 長方形のサイズを計算
        final Rect button1Rect = new Rect(
                myWidth*7/100, 0,myWidth*47/100, myHeight*2/3
        );
        final Rect button2Rect = new Rect(
                myWidth*53/100, 0,myWidth*93/100, myHeight*2/3
        );

        // 長方形を描く
        canvas.drawRect(button1Rect,paint);
        canvas.drawRect(button2Rect,paint);

        // 文字入れ
        paint.reset();
        paint.setColor(Color.WHITE);
        paint.setTextSize(myHeight*0.3f);
        paint.setTextAlign(Paint.Align.CENTER);

        canvas.drawText(button1Text,myWidth*0.27f,myHeight*0.43f,paint);
        canvas.drawText(button2Text,myWidth*0.73f,myHeight*0.43f,paint);
    }
}

途中にある2つのメソッド(firstButtonとsecondButton)は画面の切り替え時に、文字だけ変えてしまおうという手抜きですが、表示すべき文字を置き換えて、invalidate();を発行すると、drawで書き換えてくれる仕組みです。

自分で言うのもなんだけど、なんか、日曜大工的な感じがして「楽しんでるならいいんじゃない?」って思ってしまいます。

次はいよいよ、スクロールビューの設置に入ります。

第19回 swift で UIButton!

swift で ボタンを作ってみます。

        // UIButton 2個、「メニューに戻る」「奏者選択終了」
        // ボタンのサイズは共通
        let buttonSize: CGSize = CGSize(width: myWidth*0.4, height: myWidth*0.1)
        let cancelButtonPoint: CGPoint =
            CGPoint(x: myWidth*0.07, y: myHeight-myWidth*0.15)
        let okButtonPoint: CGPoint =
            CGPoint(x: myWidth*0.53, y: myHeight-myWidth*0.15)
        let buttonBackColor = UIColor(red: 50/255, green: 150/255, blue: 50/255, alpha: 255/255)
        
        
        
        let cancelButton =
            UIButton(frame: CGRect(origin: cancelButtonPoint, size: buttonSize))
        cancelButton.setTitle("メニューに戻る", for: UIControlState.normal)
        cancelButton.backgroundColor = buttonBackColor
        
        let okButton =
            UIButton(frame: CGRect(origin: okButtonPoint, size: buttonSize))
        okButton.setTitle("走者選択終了", for: .normal)
        okButton.backgroundColor = buttonBackColor
        
        addSubview(cancelButton)
        addSubview(okButton)

ボタンのサイズは横幅の40%に決め撃ちです。
左右に7%、中央に6%の余白を開けています。
文字が溢れないようにこのサイズにしましたが、大きな文字表示に設定していますと、はみ出す可能性があるかもしれません。悩むな〜。

これによって、iPAD,iPhone8,iphone5Sでシュミレートしたら、次のようになりました。

f:id:momonga117:20180517142606p:plain

ボタン内の余白が調整されているのがわかると思います。

が、しかし。しかしですよ。ねえ。(って誰に行ってるんだか・・・)
これって本当にUIButtonクラスを使う必要があるのでしょうか?

UIButtonクラスって本当に便利なんですか?
教科書には決まって最初に書かれています。
文字を入れたりとか、なんとなく他と違うし、結構お面倒だし、色々な便利な機能を用意しているのはわかるのですが、そのほとんどを使ってないし。
おまけに、addTargetがいまいちよくわからないし。(素人なもので。)

もひとつおまけなんですが、仕様がよく変わりますよね?

CGRectZEROがCGRect.zeroになってたり、CGRectMakeがなくなってたり。(ちょっと前に買った)解説本が悩みのタネになったりしてませんか?

時間に余裕のない素人が、最新情報を追っかけるのはとても辛いんですよね。

ということで、画面はあとスクロールビューの設置へと向かいます。

スクロールビューだけは攻略しないと自前ではなんともならない気がします。