くま's Tech系Blog

基本的には技術で学んだことを書き留めようと思います。雑談もやるかもね!

Swift API Design GuidelinesのConventionsについて

今回はAPIのデザインガイドラインのConventionsについてまとめようと思います

前回の命名についてのまとめは↓↓下記みてください!!

kumaskun.hatenablog.com

英語も個人的にわかりにくく計算量など聞きなれない用語も出てきていたのでまとめます

Conventionsは慣例や規則・規約と訳すのかなと思っているのですが、あまりしっくりきていません。いい訳があれば教えて欲しいです!!

計算量について

最初にO(1)ではないcomputed propertyには、計算量などの説明をコメント化するべきと記載されています

ここで、O(1)や計算量など馴染みのない言葉が出てきます。 英語を訳するのは難しくないかもしれないですが、O(1)や計算量を知らないと内容を理解するのは難しいと思います

まず、計算量とはあるアルゴリズムを実行するときに入力に対してどのくらいの時間またはメモリが必要かを表す指標です

時間の目安は時間計算量と呼ばれ、メモリの計算は空間計算量と呼ばれています。計算量とは時間計算量を表すことが多いです

そして、O(1)は計算量を表す指標です

O(1)は計算量が入力にかかわらず一定になることを表しています。例えば、配列のn番目にアクセスするなどです。 配列のサイズが変わったとしてもn番目にアクセスする際の時間は変わらないです

一方で、O(n)は計算量が入力に応じて比例します。例えば、配列の最初からn番目までの全てのデータにアクセスするなどです

プロパティは通常O(1)であることが一般的なので、O(1)ではない場合にはコメントを記載するべきです

その他の一般的な規約について

計算量の規約以外には下記のような規約が記載されています

  • Free functionsは特別な場合のみ使用して、基本的にはメソッドとプロパティを用いましょう

Free functionsとは構造体、クラス、列挙型等のメンバーではない関数のことです

Free functionsを使う場合は次の3パターンの場合に使用します

①明確なselfがない場合(クラス外で定義された可能性が高い関数の場合)

②関数が汎用的な場合(例:print(x))

③分野的に関数表記の方が自然な場合(例えば、sin(x)やmin(x)などすでに機能が提供されている場合)

  • 型とプロトコル名はUpperCamelCase(大文字から始まる)でその他はlowerCamelCase(小文字から始まる)

  • 基本的な部分で同じ意味を持つ場合、または異なるドメイン(クラスなど)で操作する場合、メソッドは名前を共有できます

例えば、次のように引数の型はそれぞれ異なりますが、基本的に同じことをしている場合にはメソッド名を同じにしても構いません

extension Shape {
  /// Returns `true` if `other` is within the area of `self`;
  /// otherwise, `false`.
  func contains(_ other: Point) -> Bool { ... }

  /// Returns `true` if `other` is entirely within the area of `self`;
  /// otherwise, `false`.
  func contains(_ other: Shape) -> Bool { ... }

  /// Returns `true` if `other` is within the area of `self`;
  /// otherwise, `false`.
  func contains(_ other: LineSegment) -> Bool { ... }
}

しかし、次のように処理の内容が異なっていたり、戻り値の型のみが異なるメソッドを複数定義するのは避けましょう

extension Database {
  /// Rebuilds the database's search index
  func index() { ... }

  /// Returns the `n`th row in the given table.
  func index(_ n: Int, inTable: TableID) -> TableRow { ... }
}

extension Box {
  /// Returns the `Int` stored in `self`, if any, and
  /// `nil` otherwise.
  func value() -> Int? { ... }

  /// Returns the `String` stored in `self`, if any, and
  /// `nil` otherwise.
  func value() -> String? { ... }
}

パラメータについて

大前提として、パラメータ名も処理内容を理解するドキュメントの役割を持つので、命名の際には意識するようにしましょう

そして、不要な情報を隠すという意味でデフォルト引数をうまく利用するようにしましょう。 デフォルト引数を利用することで処理内容を理解する負担が減ります(デフォルト引数の部分はあくまでもオプションでメインの部分は他の部分のため)

extension String {
  /// ...description...
  public func compare(
     _ other: String, options: CompareOptions = [],
     range: Range? = nil, locale: Locale? = nil
  ) -> Ordering
}

また、処理は同じなのに引数に応じていくつも定義すると読み手が理解するのに苦労するため、このような場合にもデフォルト引数は有効です

デフォルト引数を使う場合には後ろの方に配置するのが良いです。先に初期値のない引数を配置して、後からデフォルト引数を配置しましょう

引数ラベルについて

最後に引数ラベルについてです

まず、引数を区別する意味がない場合は、全てのラベルを省略しましょう。 例えば、min(number1, number2)zip(sequence1, sequence2)など引数を比較したり意味や順番などなく使用する場合などです

型的に大きくなる型変換で見られるべきイニシャライザの場合、第一引数は 変換元であり、引数ラベルはつけるべきではありません

extension String {
  //  xを基数(radix)を元に文字列に変換(BigInt → String) 
  init(_ x: BigInt, radix: Int = 10)
}

text = "The value is: "
text += String(veryLargeNumber)
text += " and in hexadecimal, it's"
text += String(veryLargeNumber, radix: 16)

逆に型的に小さくなる型変換の場合、小さくなることを記述するラベルをつけることが推奨されます

extension UInt32 { 
  // 型的に大きくなる(Int16 → UInt32)のでラベルは不要 
  init(_ value: Int16)
  // 型的に小さくなる(UInt64 → UInt32)ので、切り捨てられる旨のラベルを推奨 
  init(truncating source: UInt64)
  /// 型的に小さくなる(UInt64 → UInt32)が近似値を取得するためsaturatingのラベルを追加する
  init(saturating valueToApproximate: UInt64)
}

※型変換はMonomorphismです。 すなわち、変換元の値の違いは全て変換後の値の違いとなります。 例えば、Int8からInt64への変換は値を保つ型変換で、全ての異なるInt8の値は異なるInt64の値に変換されます。 しかし、Int64はInt8で表すことができる値より多くの値とることができるため、逆方向の変換は値を保つことができません。

第1引数が前置詞句の一部を構成するときは、引数ラベルを付けましょう。 引数ラベルは通常、前置詞で始まるべきです(例:x.removeBoxes(havingLength: 12)) ここはあっているか微妙なところです・・havingLengthは前置詞句ではないような気が

そして、2つ(複数)の引数が一つの抽象概念(処理の内容)の一部を表している場合は例外です。 例えば次の例は良くない例です

a.move(toX: b, y: c)
a.fade(fromRed: b, green: c, blue: d)

toXやfromRedは他の引数にもかかります。toはXにもyにもかかる内容です。 その場合には次のようにメソッド名に前置詞を追加することでわかりやすくなります(前置詞の後に引数ラベルを追加する)

a.moveTo(x: b, y: c)
a.fadeFrom(red: b, green: c, blue: d)

それ以外の場合、第1引数が英文的なフレーズの一部を構成していない場合はラベルのままにしておくべきです。 そして、フレーズが正しい意味を伝えることが大切だということに注意してください。 次の例は英文法的には正しいかもしれませんが、間違った解釈される可能性があるのでよくない例です

view.dismiss(false)
words.split(12)

falseや12が意味するものが間違った解釈をされる可能性があるので、次のようにラベルを表示させるのが望ましいです

view.dismiss(animated: false)
let text = words.split(maxSplits: 12)
let studentsByName = students.sorted(isOrderedBefore: Student.namePrecedes)

今回のドキュメント

www.swift.org

www.appypie.com

Swift API Design Guidelinesの命名について

Swift API Design Guidelinesという実装でのコーディングルールをまとめた公式ドキュメントについてです

コメントの書き方や命名などのコーディング規約のようなものです。今回は命名についてまとめようと思います

今回は自分の英語の勉強のアウトプットが主な目的になるので、下記のドキュメントをご自身で確認するのをオススメします!

www.swift.org

命名

曖昧さを避けるために全ての単語を含めよう

呼び出す人にとって曖昧な意味と捉えられないように、全ての単語を含めましょう

下記パターンは良い例です

extension List {
  public mutating func remove(at position: Index) -> Element
}

employees.remove(at: x)

しかし次のパターンは良くない例です

employees.remove(x)

よくないパターンだとx番目のデータを削除するのかxというデータ自体を削除するのか呼び出す人は判断しにくいです。 今回はatをつけることでどんな処理を行うのかがわかりやすくなるため単語で補完できるようにするのが望ましいです

不必要な単語は省く

先程、曖昧さを避けるために全ての単語を含めようと述べたのに?と思う人もいるかもしれません

全ての単語は、呼び出し側にとって意味のある情報であるべきであって、意図を明確にしたり意味の違いを明確にするために多くの単語を使うこともあります

ただ、読み手にとって不要な情報(なくても理解できるもの)は省略しよう

例えば次のような例は望ましくないです

public mutating func removeElement(_ member: Element) -> Element?

戻り値の型がElementなのは定義を確認したらわかるようになっているので、関数名がremoveElementとなっているのは冗長です

次の方が望ましいです

public mutating func remove(_ member: Element) -> Element?

役割に応じて変数・パラメータ・関連型を命名する

型制約ではなく、役割に応じて変数・パラメータ・関連型を命名するのが望ましいです

var string = "Hello"
protocol ViewController {
  associatedtype ViewType : View
}
class ProductionLine {
  func restock(from widgetFactory: WidgetFactory)
}

上記の場合だとvar stringassociatedtype ViewTypewidgetFactoryなど型名を入れていて何の役割を果たすのかがわかりにくいです。 下記のように役割を明確にするような命名が望ましいです

var greeting = "Hello"
protocol ViewController {
  associatedtype ContentView : View
}
class ProductionLine {
  func restock(from supplier: WidgetFactory)
}

ただし、関連付いた型がProtocol制約に密接に結びついて、Protocol名が役割になっている場合、 衝突を回避するためにProtocol名にProtocolを付けるのは問題ないです

弱い型情報には捕捉を行うようにする

パラメータ型がNSObject, Any, AnyObject, Int, Stringなどの基本型の場合、使用時の型情報と名前で意図をちゃんと伝えられない可能性があります。 下記の場合には定義を見ると内容が理解できるかもしれないですが、使用する場合には何が行われるのかがわからない可能性が高いです

func add(_ observer: NSObject, for keyPath: String)

// 曖昧
grid.add(self, for: graphics)

そのため次のように基本型の前に役割を示す名詞を付けるなどの補足を行い呼び出す側でも処理を理解できるようにします

func addObserver(_ observer: NSObject, forKeyPath path: String)
grid.addObserver(self, forKeyPath: graphics)

スムーズに英語を読む感覚(文法的)で命名する

例えば、下記の処理はx insert y position zと読むことになります。 ただし、文法的には正しくないと思います

x.insert(y, position: z)

下記の場合であれば、x insert y at zとなり文法的にも正しいです。 役割のわかりやすさだけでなく文法的にも正しいのかをチェックするのが望ましいです

x.insert(y, at: z) 

ただし、第二引数より後の引数が呼び出しにとっての中心的な役割を持たない場合、文法的におかしくても許容されます

逆にいうと、呼び出しの意味の主要なものから引数にしていくのが望ましいのかもしれません

initializerやfactoryメソッドの最初の引数は、関数名で始まる英語フレーズにしなくていい

今まではスムーズに英語を読む感覚で命名すると記載しましたが、例外があります

initializer(初期化処理)やfactoryメソッドの場合には文法的でスムーズに英語を読む感覚で定義する必要はありません。 下記のように初期化処理で第一引数を含めたフレーズにする必要はありません

let foreground = Color(havingRGBValuesRed: 32, green: 64, andBlue: 128)
let newPart = factory.makeWidget(havingGearCount: 42, andSpindleCount: 14)
let ref = Link(to: destination)

次のように引数名はスムーズに読めるような形にする必要はありません

let foreground = Color(red: 32, green: 64, blue: 128)
let newPart = factory.makeWidget(gears: 42, spindles: 14)
let ref = Link(target: destination)

副作用によって関数とメソッドの名前をつける

ガイドラインでは「副作用に応じてメソッドに名前を付ける」と記載されています

副作用とは、メソッドの呼び出しによってオブジェクトの状態、または別のオブジェクトの状態が変化することを指します

副作用があれば名前を動詞に、副作用がなければ名前を名詞にすることが推奨されています

そして、可変(mutating)・不変(nonmutating)メソッドのペアは一貫した命名をしましょう

インスタンスの内容を更新するメソッド(可変) インスタンスの内容を更新しないメソッド(不変)
x.sort() z = x.sorted()
x.append(y) z = z.appending(y)

操作が動詞で自然に説明できる場合は、その命令形をインスタンスの内容を更新するメソッドに使います

そして、edまたはingをつけた名前を対応するインスタンスの内容を更新しないメソッドに使います(動詞が直接目的語を持つためedをつけるのが文法的に正しくない場合に動詞の現在分詞を使用しingをつけてインスタンスの内容を更新しないメソッドに名前をつける)

また、操作が名詞で自然に説明できる場合はインスタンスの内容を更新しないメソッドにその名詞を使い、対応するインスタンスの内容を変更するメソッドには頭にformをつけた名前をつけましょう

インスタンスの内容を更新するメソッド(可変) インスタンスの内容を更新しないメソッド(不変)
x = y.union(z) y.formUnion(z)
j = c.successor(i) c.formSuccessor(&i)

その他に以下のような規約があります

  • インスタンスの内容を更新しないBool 値を返すメソッドとプロパティはレシーバに関する表明として読めるべき 例: x.isEmpty, line1.intersects(line2)
  • 何であるのか を記述するプロトコルは名詞として読めるべき(例: Collection)
  • 能力 を記述するプロトコルは接尾語 able, ible, ing を使用して名前をつけるべき (例: Equatable, ProgressReporting)
  • その他の型、プロパティ、変数、定数の名前は名詞として読めるべきです

専門用語を使う際に気をつけるポイント

より一般的な言葉で意味が伝わる場合は、よくわからない専門用語を避けましょう。 「皮膚(skin)」でも良い場合「表皮(epidermis)」とは言わないでください。 専門用語は重要な意味が失われてしまわないようにする時だけ使用してください

一般用語より専門用語を使うのは、それがなければ曖昧で不明瞭になるモノを正確に示すために使用します。 例えば、API のような技術用語も確立された意味を示す場合に使いましょう

逆に専門用語に慣れ親しんだ人にとって、違う意味で使おうとしないでください

また、略語は避けましょう。 略語、特に一般的ではないものは、実質的に技術的専門用語です。 なぜなら理解できるかどうかは略されていない形式に正しく翻訳できるかどうかに拠るためです

そして、既にある文化への適合性を無駄にしてまで、初心者向けに用語を最適化しないでください

配列を使う場合に、Listの方が意味を取りやすいかもしれませんが、連続したデータ構造はListのような単純な用語よりもArrayと名付ける方がよいです。 配列は現代のコンピューティングの基礎であるため、全てのプログラマーは配列とは何か知っています。 このように全てのプログラマーが慣れ親しんでいる用語を優先的に使いましょう。 また、数学のような特定のプログラミングの分野ではverticalPositionOnUnitCircleAtOriginOfEndOfRadiusWithAngle(x)といった説明的なフレーズよりsin(x)といった広く認められている用語が好ましいです。 完全な単語はsineですが、sin(x)は何十年ものあいだプログラマーにとって一般的なので、sinを使うべきです

SwiftのDictionaryについて

今回はSwiftのDictionary型についてまとめようと思います

Dictionaryとは?

Dictionaryとはキーと値をセットにした配列のようなものです

例えば、Dictionaryでは、「名前 : “山田”」「年齢 :23」「性別 : “男”」のようにキーと値をセットで作成します。 この場合にはキーが名前・年齢・性別で、値は“山田”・23・“男”です

キーに紐づく値を参照できるため、ディクショナリを辞書型とも呼びます

注意点としては順番が保証されているわけではないので、何番目に取得できるなどの処理を行う場合には注意が必要です

基本的な使い方は次のように定義します

let fruits: Dictionary<String, Int> = 
["りんご": 100, "オレンジ": 80, "メロン": 700]

// キーを指定することで値を取得できる
fruits["いちご"]
 
for (key, value) in fruits {
    print("\(key)は\(value)円です。")
}

今回はキーと値をそれぞれString型とInt型で定義しています。定義と違う型を指定するとエラーになるので注意をしてください

そして値を追加・更新する場合には次のように行います

let fruits: Dictionary<String, Int> = 
["りんご": 100, "オレンジ": 80, "メロン": 700]

// キーが存在する場合には更新、ない場合には追加
fruits.updateValue(300, forKey: "いちご")

// キーが存在する場合には更新、ない場合には追加
fruits["いちご"] = 300

fruits["いちご"] = 300の方が一般的に使われていると思います

削除する場合には次のように対応します

let fruits: Dictionary<String, Int> = 
["りんご": 100, "オレンジ": 80, "メロン": 700]

//要素の削除
fruits.removeValue(forKey:"りんご")

Dictionaryの細かい処理

ここからはもう少し細かいDictionaryで行う処理を見ていこうと思います

キーでソートする

1つ目はキーでソートするパターンです。 Dictionaryは順番が保証されているわけではないのでソートする必要が出てくるかもしれません

var dic = [
    "C" : 2,
    "A" : 3,
    "B" : 4,
]

//     [(key: "A", value: 3),
//     (key: "B", value: 4),
//     (key: "C", value: 2)]の順で出力される
print(dic.sorted(by: { $0.key < $1.key}))

<で昇順、>で降順にソートされます

グルーピングする

groupingを使うことで、配列のデータを特定の条件でDictionaryにすることができます

let names = ["Apple", "Grape", "Apricot", "Guava"]

// グルーピングする["G": ["Grape", "Guava"], "A": ["Apple", "Apricot"]]
let initialGroup = Dictionary(grouping: names) { $0.first ?? "#" }

// 文字数をキーとして文字数が同じデータをまとめる
// [5: ["Apple", "Grape", "Guava"], 7: ["Apricot"]]
let countGroup = Dictionary(grouping: names) { $0.count }

まとめ

ここまでDictionaryの処理を確認しましたが、本来はDictionaryを使うのは設計を見直すべきかなと個人的には感じています

しかし、要件上使わざるをえない場合もあるかもしれないのでそのためのリファレンスになればいいかなと思います

参照

docs.swift.org

teratail.com

qiita.com

www.choge-blog.com

レイアウトの背景をカスタマイズする

今回はレイアウトファイルで、背景をカスタマイズする場合の方法についてです

レイアウトで枠線をつけたり、角丸にしたりカスタマイズしたい場合Attributesで設定できるものもあれば、設定できないものもあります

そして、設定できない場合にはxmlファイルを利用することで画像を用意しなくてもボタンの角を丸くしたり、レイアウトにグラデーションをつけたりすることができます

作成するファイルはres/drawableフォルダに格納します。 drawableは背景画像の設定やグラデーションの設定などを格納していくフォルダです。 drawableフォルダ内に新たな設定用のxmlファイルを作って呼び出します。

このフォルダは本来、端末の画面解像度に関係なく、すべての端末から参照させたい場合のものを格納しておくところです(あと画像を格納します)

カスタムドローワブルには多くの種類があります。今回はよく使うものを抜粋して説明します

全てを確認する場合には参照に記載している公式ドキュメントを確認してください

Shape

まずはShapeドローワブルです

shapeドローワブルはBitmapなどの画像そのものを使わずにグラフィック的な画像を表現するものです。 円や四角形など単純なものに使います

shapeドローワブルは<shape>を使って作成します。一例ですが、次のように使います(今回はradius_button.xmlというファイル名で作成しています)

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">

    <solid android:color="#fff123" />
    <corners android:radius="10dp" />
</shape>

shapeでは以下の4種類の形状が用意されています

  • rectangle : 四角形
  • oval : 楕円
  • line : 直線
  • ring : 円形

そして、上記例ではsolidcornersを定義しています

solidは背景色、cornersは角丸を定義しています

子要素として設定できる項目は数が多いので参照に記載している公式ドキュメントを参照してください

ここまででShapeドローワブルを作成しましたが、レイアウトに適用させるためには次のようにandroid:backgroundに設定することで適用できます

<Button
        android:id="@+id/selectorButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="10dp"
        android:layout_marginTop="10dp"
        android:background="@drawable/radius_button"
        android:text="Radius Button By Selector"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/backgroundNullButton" />

また、次のようにImageViewにも適用できるため画像よりリソースを省略できることもあります

<ImageView
        android:id="@+id/selectorImageView"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:layout_marginStart="20dp"
        android:layout_marginTop="10dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/button"
        app:srcCompat="@drawable/radius_button" />

適用したものをビルドすると次のようになります

layer-list

次にlayer-listドローワブルです

layer-listは要素を重ねあわせて使います。画像などをレイヤー構成で重ねることができるリソースで、複数の画像をひとつのリソースとして取り扱うことができます

作成する場合には<layer-list>タグを使用します。今回は、画像を入れたplaceholderを作成します

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/colorPrimary" />
            <size
                android:width="100dp"
                android:height="100dp" />
        </shape>
    </item>
    <item
        android:width="24dp"
        android:height="24dp"
        android:drawable="@drawable/ic_home_black_24dp"
        android:gravity="center" />
</layer-list>

itemタグが2つの構成になっていると思います。 レイアウトの時と同じで下の方が前面に表示されます。 なので、画像が前面に表示されて、その後ろに四角で背景色がcolorPrimaryの画像が重なっている構成になります

placeholder用の画像だけ用意してもらい、Drawableで必要サイズの画像を用意してしまえば、 画像分の容量が削減でき、デザイナーの負担も減ると思います。

この作成したドローワブルをShapeの時と同じようにbackgroundに設定すれば背景に反映されます

また、これで作成した画像はscaleType="fitXY"で配置すると、伸縮するのが背景色部分になり、ロゴのサイズ自体は変わりません。 これで画像を用意する必要が減ると思います。

Selector

次にSelectorドローワブルです

Selectorドローワブルはオブジェクトの状態に応じて、複数の異なる画像を使用して同じグラフィックを表します。 今回はボタンを押した時と押していない時で表示を分けるドローワブルを作成します

Selectorドローワブルは<selector>タグを使用します

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="false">
        <shape android:shape="rectangle">
            <corners
                android:radius="10dp"/>
            <solid
                android:color="#cccccc"/>
        </shape>
    </item>
    <item android:state_pressed="true">
        <shape android:shape="rectangle">
            <corners
                android:radius="10dp"/>
            <solid
                android:color="#fff123"/>
        </shape>
    </item>
</selector>

2つのitemがありますが、2つが重なっているというよりは条件で表示を分岐させているイメージが近いかもしれません

state_pressedがボタンを押しているか押していないかを判定する値で、プッシュ状況で表示を変えています

この作成したドローワブルをbackgroundに設定すれば背景に反映され、タップした時に背景が変更されます

ripple

最後にRippleドローワブルです

Rippleドローワブルはタップしたとき波形で視覚的にタップしたのがわかるようなものです(RippleEffectともいいます)

Rippleドローワブルは<ripple>タグを使用します

<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="?android:attr/colorControlHighlight">
    <!-- 波紋の色 -->
    <item android:id="@android:id/mask">
        <shape android:shape="rectangle">
            <solid android:color="#000000" />
            <corners android:radius="3dp" />
        </shape>
    </item>
    <!-- ボタンの背景-->
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@color/colorPrimary" />
        </shape>
    </item>
</ripple>

Rippleタグに指定するのは波紋の色で、これにより白いボタンに青い波紋を広げるなどのカスタマイズが出来ます

maskには波紋が広がる範囲を指定します。(android:id="@android:id/mask"の部分) maskは指定しなかった場合、 適用したレイアウトの範囲を超えて波紋が広がるので注意が必要です。 波紋が広がる範囲はα値で決定されるため、背景が透明のボタンの場合、maskを指定しないとRipple Effect効果は適用されません。 また、単なるShapeのほかに透過pngによるbitmapも指定可能で、複雑な形に波紋エフェクトをかけることも出来ます。

あとは作成したドローワブルを適用したいオブジェクトのBackgroundに指定することでRippleEffectを実現することが出来ます

※ボタンにRippleを適用する場合には次のようにandroid:background="?attr/selectableItemBackground"を指定することでデフォルトのRippleEffectが適用されます。 背景色などでbackgroundに他の値を指定している場合にはforegroundに指定しましょう

<Button
        android:id="@+id/button12"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="10dp"
        android:layout_marginTop="10dp"
        android:text="Default Ripple"
        android:background="?attr/selectableItemBackground"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="parent" />

まとめ

カスタムドローワブルについて説明しましたが、今回紹介したものはまだほんの一部なので公式ドキュメントを確認していろんなパターンを試してください

また、MaterialComponentだと角丸など設定できる項目が増えているかもしれないので確認して必要に応じて使うようにしましょう

参照

qiita.com

developer.android.com

developer.android.com

Kotlinのオブジェクト式について

個人的な意見ですが、オブジェクト式や無名オブジェクトを理解するのに若干苦労したのでまとめようと思います

合わせて、無名関数も苦労したので合わせて記載しようかと思います

オブジェクト式とは?

オブジェクト式では無名オブジェクト(無名クラス)の定義・生成を同時に行います

また、無名オブジェクトはクラスやオブジェクト自体に名前が無いのが特徴です

オブジェクト式を使って生成したオブジェクトは、変数に格納するか、関数に渡すパラメータとして使用します(そうしないと参照できないため)

オブジェクト式の定義方法

objectの後に括弧({})を使い、その中にプロパティやメソッドを定義します

例えば次のような使い方です

val test = object {
    val number = 1
    fun run() = "テスト"
}
println(test.number)  //1
println(test.run())   //テスト

インターフェイスを継承する

オブジェクト式はクラスや1つ以上のインターフェイスを継承できますアプリ開発でよく使う例は、インターフェイスを実装した無名クラスです

interface TestInterface {
    fun showMessage(): String
}
val x = object: TestInterface {
   //インターフェイスのメソッドをオーバーライド
   override fun showMessage(): String {
       return "メッセージ表示"
    }
}

println(x.showMessage())   //メッセージ表示

さらに無名クラスは関数のパラメータ(引数)として、関数に渡すことができます

先程使用したインターフェースを引き続き使用します。 まず関数定義は次です

//インターフェイスを実装したオブジェクトを引数に取る
fun runFunction(o: TestInterface) {
    println(o.showMessage())
}

そして関数を実行するのは次のようになります

runFunction(object: TestInterface {
    override fun showMessage(): String {
        return "スタート"
    }
})

無名関数

無名関数は無名オブジェクトと同じように宣言と同時に関数オブジェクトになるものです。また、関数自体に名前がないです

fun(a:Int, b:Int):Int = a+b

無名関数は値として使用するため変数にセットできます

val addItem = fun(a:Int, b:Int):Int = a+b
println(addItem(3, 6))

ちなみに、ラムダ式も無名関数の一種です

val addLambda = {a:Int, b:Int -> a+b}
println(addLambda(3, 6))

無名関数は高階関数の一種です。最後に高階関数について軽く説明します

高階関数

Kotlinにおいて高階関数は第一級オブジェクトとして扱えます。第1級オブジェクトは関数リテラルとして表現できます

関数リテラルとは、関数を 直接、波括弧 「{ }」 内に記述する記法です。関数リテラル記法で記述すると、コードが即座にそのまま式として渡せます

つまり、関数を引数で受け取ったり、関数を返したりする関数のことを高階関数と呼びます

例えば次のような処理です

// body は () -> T の関数型で、引数なしで T 型の戻り値を返す関数を表している。
fun <T> lock(lock: Lock, body: () -> T): T {
  lock.lock()  // 処理を実行する前にロックする
  try {
    return body()  // 渡された関数を実行
  }
  finally {
    lock.unlock()
  }
}

上記の関数を呼び出す場合には引数に別の関数を渡します

// toBeSynchronized()関数を宣言
fun toSynchronize() = test()

// このtoBeSynchronized()関数を渡したいのだが、それには::で参照を取得する
val result = lock(lock, ::toSynchronize)

もしくは次のようにラムダを渡すこともあります(こちらの方が一般的)

val result = lock(lock, { test() })

// 最後の引数が関数なら、引数を並べる( )括弧の外に出す
lock (lock) {
  test()
}

引数として関数を渡すと、変数名の後に: ()->Stringのように、関数の型を明示します

例えば、Intを引数として受け取り、Stringを返す関数は、(Int)->Stringのように表現します。 ->の左が因子、 ->の右側が戻り値を意味します

そして、引数は括弧で囲む必要があります

もし引数が2つ以上ある場合、 (Int, Int)->Stringように表現することができます。 引数がない場合は、 ()->Stringのように括弧の中を空にします

またここまでの説明をもとにしてよく使うsetOnClickListenerを見ていきます

setOnClickListenerは Viewクラスのメソッドであり、それを継承する任意のクラスで使用できます。 本来であればonClickメソッドをoverrideした状態までをコード化するのですが、次のように省略することもできます

// View.OnClickListenerの単一抽象メソッドはonClickメソッドで、これらはViewを引数にとり戻りなしで共通
button.setOnClickListener(Object: View.OnClickListener {
    override fun onClick(view View?){
        TapAction()
    }
})

↓

// これを高階関数で省略すると
button.setOnClickListener{ view -> TapAction() }

↓

// ラムダのviewパラメータは使用しないので更に省略する
button.setOnClickListener{ TapAction() }

参照

dogwood008.github.io

qiita.com

RecyclerViewのGridLayoutについて

GridLayoutはリストを格子状に並べるという形式です

以前RecyclerViewについての記事を投稿した際にLayoutManagerにGridLayoutManagerというパターンがあるというのを記載しています

kumaskun.hatenablog.com

今回はGridLayoutManagerについて少し深掘りしようと思います

全てのセルが同じViewHolderで構成されているパターン

まずは全てのセルが同じViewHolderで構成されるパターンです

基本的にLinerLayoutManagerと作り方は同じでAdapterを作成してRecyclerViewと紐付けをします

ただ、GridLayoutManagerは紐付け方が異なるのでそこにフォーカスを当てて説明します

Adapterやセル(アイテム)のレイアウトは他のリスト表示の時と変わらないので下記まとめています

class HomeAdapter(private val countList: List<Int>): RecyclerView.Adapter<HomeAdapter.CountListRecyclerViewHolder>() {
    private lateinit var listener: HomeAdapterListener
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeAdapter.CountListRecyclerViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val view = inflater.inflate(R.layout.grid_list, parent, false)
        return CountListRecyclerViewHolder(view)
    }

    override fun getItemCount(): Int {
        return countList.size
    }

    override fun onBindViewHolder(holder: CountListRecyclerViewHolder, position: Int) {
        holder.countText.text = countList[position].toString()

        holder.itemView.setOnClickListener {
            listener.contentTapped(position)
        }
    }

    class CountListRecyclerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        var countText: TextView = itemView.findViewById(R.id.gridTextView)
    }

    fun setListener(listener: HomeAdapterListener) {
        this.listener = listener
    }

    interface HomeAdapterListener {
        fun contentTapped(position: Int)
    }
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="6dp"
    app:cardElevation="6dp"
    app:contentPadding="8dp"
    app:cardUseCompatPadding="true">
    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <View
            android:id="@+id/image_view"
            android:layout_width="match_parent"
            android:layout_height="160dp"/>

        <TextView
            android:id="@+id/gridTextView"
            android:textSize="20sp"
            android:textColor="@android:color/black"
            android:textAlignment="center"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
    </LinearLayout>
</androidx.cardview.widget.CardView>

異なるのはLayoutManagerです。格子状に並べるためにはGridLayoutManagerを使用します

val countList = listOf(1, 2, 3, 4, 5, 6, 7, 8)
val adapter = HomeAdapter(countList)
homeRecyclerView.adapter = adapter
homeRecyclerView.layoutManager = GridLayoutManager(
    requireContext(),
    2,
    RecyclerView.VERTICAL,
    false
)

adapter.setListener(object: HomeAdapter.HomeAdapterListener {
    override fun contentTapped(position: Int) {
        // セルをタップした時の処理
    }
})

今回のGridLayoutManagerで横に2つずつ並べるようなリスト表示の設定を行っています(GridLayoutManagerの初期化の引数の2つ目でspanCountを指定します)

ビルドしてみると次のような表示になります

セルのマージンを調整する

GridLayoutは基本的にはセル間のマージンがありません。マージンを追加する場合には例えばセルの右側に10dpマージンを追加するなどの方法が考えられます

しかし、その方法だと全てのセルに同じマージンが追加されることになります。特定のセルのみマージンを変更したい場合には別の方法で対応する必要があります

今回はItemDecorationを使う方法で対応します

ItemDecorationクラスはアイテムの装飾を行います。例えば、区切り線や今回のようにマージンを追加したりできます

ItemDecorationを継承したカスタムクラスを作成して、RecyclerViewに追加します

class CustomGridItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
    private val margin = 120

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val params = view.layoutParams as? GridLayoutManager.LayoutParams
        val manager = parent.layoutManager as? GridLayoutManager
        val spanIndex = params?.spanIndex ?: 0
        val spanSize = params?.spanSize ?: 1
        val spanCount = manager?.spanCount ?: 1

        // セルのindex
        val itemPosition = parent.getChildAdapterPosition(view)

        // セルのindexが2番目以下
        val isTop = itemPosition < 2

        val isStart = spanIndex == 0
        val isEnd = spanIndex + spanSize == spanCount

        // 今回はviewHolderに応じて処理を行っているが、1つのviewHolderで1つのクラスを作成しても問題ない
        when (parent.findContainingViewHolder(view)) {
            is HomeAdapter.CountListRecyclerViewHolder -> {
                // outRect.set(leftのマージン, topのマージン, rightのマージン, bottomのマージン)
                outRect.set(0, if (isTop) margin else 0, 0, 0)
            }
        }
    }
}

今回は0番目と1番目の上部に120のマージンを表示させるようにしました(getItemOffsetsはアイテムの上下左右に空間を設ける処理を行います)

ItemDecorationを継承したカスタムクラスを作成したら次のようにRecyclerViewに追加します

homeRecyclerView.addItemDecoration(CustomGridItemDecoration(requireContext()))

これで次のように特定のセルにマージンを追加できます

1列に表示させる数を変更する

先程までは1列に2つ表示させるという規則的なリストを作成しました

ただ、1列目は1つ、2列目は2つというように不規則に表示させたい場合もあると思います

ここでは不規則に表示させるパターンについて説明しようと思います

1列に表示させる数を設定するためにはGridLayoutManagerクラスのSpanSizeLookupを使います。 SpanSizeLookupのgetSpanSizeで列に何個表示させるかを指定します

val layoutManager = GridLayoutManager(requireContext(), 2).apply {
    spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
        override fun getSpanSize(position: Int): Int {
   // 2番目のセルは2つ分のサイズを使用して、それ以外は1つ分(半分)のサイズを使用する
            if (position == 2) {
                return 2
            }
            return 1
        }
    }
}

homeRecyclerView.layoutManager = layoutManager

今回の場合にはGridLayoutManager(requireContext(), 2)で1列に2つ表示するように設定した上で2番目は2つ表示させるサイズを1つのセルに使い、 それ以外は1個分のサイズつまり画面の半分のサイズでセルを表示させるようにspanSizeLookupを設定しています

ビルドすると次のようになります

こうすることで不規則な個数を表示させることができますが、if (position == 2)のようにpositionを固定できない場合にはどうしますか?むしろ、固定できない場合が多いと思います

そのような場合にはViewTypeで区別することができます

まずAdapterでは他のAdapterとは重複しないViewTypeを指定します。 Int型を指定しなければいけないのでレイアウトIDを指定することが多い気がしますが、重複しなければいいです

override fun getItemViewType(position: Int): Int {
    return R.layout.grid_list
}

そしてadapterのViewTypeを元にしてアイテムのサイズを指定します。 この場合には条件分岐をposition固定にしなくていい反面、Adapterを複数作る必要があります

val layoutManager = GridLayoutManager(requireContext(), 2).apply {
    spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
        override fun getSpanSize(position: Int): Int {
            return when (adapter.getItemViewType(position)) {
                R.layout.grid_list -> 1 
            }
        }
    }
}

ItemDecorationの補足

ここで少しItemDecorationについて補足します。ItemDecorationの大まかな概要は次のようになります

class CustomGridItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
    // アイテムの上下左右へ空白(透明)を設ける
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
    }

    // アイテムが描画される前に描画されるため、RecyclerViewの下に装飾を表示
    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDraw(c, parent, state)
    }

    // アイテムが描画された後に描画されるため、RecyclerViewの上に装飾を表示
    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)
    }
}

onDrawOveronDrawは次のイメージです

少し前のItemDecorationの解説でで区切り線を追加したりするという説明をしたと思います

区切り線を入れたい場合は、DividerItemDecorationという既成のItemDecorationがAPIに用意されています

val deco = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
homeRecyclerView.addItemDecoration(deco)

区切り線をカスタマイズしたい場合やRecyclerViewをカスタマイズしたい場合には、getItemOffsetでサイズを指定してonDrawもしくはonDrawOver(あるいは両方)で描画処理を行うというということを 意識して実装しましょう!

参照

qiita.com

star-zero.medium.com

star-zero.medium.com

AndroidでWebViewを使ってみる

今回はAndroidのWebViewについてです

iOSと違う部分もあるので見ていきましょう!

Webページを表示させる

Webページを表示させるだけであれば簡単にできます

前提としてAndroidManifest.xmlでインターネットの権限の設定を行ってください

<manifest ... >
    <uses-permission android:name="android.permission.INTERNET" />
</manifest>

レイアウトファイルでwebviewを作成して、表示させるURLを読み込む処理を行うのみです

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <WebView
        android:id="@+id/webView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
webView.loadUrl("http://www.example.com")

表示させるのはこれだけでできます。ここからは細かい設定について説明します

WebViewの設定を追加する

WebViewの設定はWebSettingsを利用します。設定は次のように行います(一部抜粋します)

// JavaScriptを有効にする(ここでのwebViewはレイアウトのID)
webView.settings.javaScriptEnabled = true

// ピンチ操作によるズームイン・ズームアウトの有効化
webView.settings.builtInZoomControls = true

// ダブルタップによるズームイン・ズームアウトの有効化
webView.settings.useWideViewPort = true

// ズームボタン(右下に表示される±のルーペ)の表示・非表示切り替え
webView.settings.displayZoomControls = true

// // Storageの使用許可の設定
webView.settings.domStorageEnabled = true

// trueの場合にはHTMLのwidthがWebViewより大きい時に全体を表示するようによしなに調整される
webView.settings.loadWithOverviewMode = true

// ズーム機能をサポートするか否か
webView.settings.setSupportZoom(true)

webViewClientの設定

webViewClientはWebView内のセキュリティやルーティングなどのほとんどのアクションを担当するオブジェクトです

webViewClientの主な処理は次です

webView.webViewClient = object :WebViewClient() {

      // WebView内の別のリンクを表示させる際に
      override fun shouldOverrideUrlLoading(
          view: WebView?,
          request: WebResourceRequest?
      ): Boolean {
          // shouldOverrideUrlLoadingから別のActivityやアプリを起動する場合はtrueを返す。falseを返すとWebView内にロードした結果を表示
          return false
      }

      // ページ読み込み開始時
      override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
          super.onPageStarted(view, url, favicon)
      }

      // ページ読み込み完了時
      override fun onPageFinished(view: WebView?, url: String?) {
          super.onPageFinished(view, url)
      }

      // アクセスエラーなどのWebリソースの読み込みエラー発生時の処理
      override fun onReceivedError(
          view: WebView?,
          request: WebResourceRequest?,
          error: WebResourceError?
      ) {
          super.onReceivedError(view, request, error)

          // ここを追加しないと画像などが表示できない場合もエラー扱いになる
          if (request.isForMainFrame) {
          }
      }

      // SSLエラー時
      override fun onReceivedSslError(
          view: WebView?,
          handler: SslErrorHandler?,
          error: SslError?
      ) {
          super.onReceivedSslError(view, handler, error)

          handler?.cancel()
    }
}

注意点としてはonReceivedErrorを受け取ってもonPageFinishedは実行されるので、画面の制御などに気をつけましょう

HTMLを表示させる

次にHTMLを表示させる方法です

HTMLはassetsフォルダを作成しておきます

assetsフォルダ

HTMLファイルを置いたら、呼び出してロードするだけで表示できます

// 今回の場合にはtest.htmlというファイルを格納している想定です
webView.loadUrl("file:///android_asset/test.html")

CSSを適用させるにはページ読み込み完了したらCSSファイルを読み込む処理を行います

CSSはHTMLと同様にassetsフォルダに置いているという条件付きです。またonPageFinishedで行います

private fun injectCSS() {
            try {
                val inputStream = assets.open("style.css")   // CSSファイル名を指定
                val buffer = ByteArray(inputStream.available())
                inputStream.read(buffer)
                inputStream.close()
                val encoded = Base64.encodeToString(buffer , Base64.NO_WRAP)
                webframe.loadUrl(
                    "javascript:(function() {" +
                            "var parent = document.getElementsByTagName('head').item(0);" +
                            "var style = document.createElement('style');" +
                            "style.type = 'text/css';" +
                            // Tell the browser to BASE64-decode the string into your script !!!
                            "style.innerHTML = window.atob('" + encoded + "');" +
                            "parent.appendChild(style)" +
                            "})()"
                )
            } catch (e: Exception) {
                e.printStackTrace()
            }

        }

Javascriptとの連携

Javascriptからアプリの処理を実行したり、アプリからJavaScriptのメソッドを実行したい場合があると思います

Javascriptとの連携はWebChromeClientを使います

WebChromeClientはブラウザー周辺のchromeの要素を変更するイベントに反応するためのイベントインターフェイスです。 JavaScript、favicons、進行状況と現在のページのタイトルの更新などが含まれます

Javascriptからアプリの処理を実行する

例えば、Javascriptでボタンのタップアクションをアプリ側で受け取りアプリで処理を行う場合です

HTMLは次の例を使用します

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
        <script type="text/javascript">
            function showMessage() {
                alert("showMwssage");
            }
            function setMessage(message) {
                document.getElementById("text").innerHTML = message;
            }
        </script>
        <input type="button" value="Button"
               onClick="showMessage();" />
        <p id="text"></p>
    </body>
</html>

HTMLで設定されるfunction showMessage()でalertを設定して次のようにonJsAlertでメッセージを取得できます

webView.webChromeClient = object :WebChromeClient() {
    override fun onJsAlert(
       view: WebView?,
        url: String?,
        message: String?,
        result: JsResult?
    ): Boolean {
        if (!message.isNullOrEmpty()) {
            Log.d("jsAlertMessage", message)   // showMwssageが取得される
         }
        return true
    }
}

アラートメッセージを取得したら処理が行われるようにしたらJavascriptからアプリの処理を実行したことになります

アプリからJavaScriptのメソッドを実行する

今度は先程と逆でアプリからJavascriptを実行する方法です

次のようにevaluateJavascriptJavascriptの関数を呼び出します

val message = "TEST"
webView.evaluateJavascript("setMessage(`${message}`);")

注意点

オフラインなどでWebViewが表示できない時はデフォルトのエラー画面ではなく、エラーメッセージを表示させる・エラーイラストを表示させるなどの対応を行うのが望ましいです

そうしないとリリース時にNGを受けることがあります

iOSではNGを受けないかもしれないですが、同じ対応を行うのが望ましいです

参照

android-tech.jp

developer.android.com

qiita.com

qiita.com

https://android.camposha.info/ja/android-webview/#gsc.tab=0android.camposha.info

qiita.com

qiita.com

ExpandableListViewを使ってみる

Androidで開閉式のアコーディオンテーブルを作成する際にExpandableListViewを使います

あまり使う機会がなく記事もあまりなさそうなので、記事にしようと思います

ExpandableListViewを使う手順

ExpandableListViewをアプリで使う手順は次の手順で行います。ExpandableListViewはListViewを継承しているため基本的にはListViewと同じ手順を踏むことになります

  1. ExpandableListViewの配置
  2. リストグループビューとリストアイテムビューのレイアウトの作成
  3. リストビューの設定(Adapterなど)

ExpandableListViewの配置

まずはExpandableListViewを配置します(Activity)

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ExpandableListView
        android:id="@+id/expandableList"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

リストグループビューとリストアイテムビューのレイアウトの作成

次にリストグループビューとリストアイテムビューのレイアウトを作成します。 リストグループビューが親要素でリストアイテムビューは子要素に相当します

今回は親要素と子要素を同じレイアウトにします。 今回はexpandable_cell.xmlという名前で作成します

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/expandableTitleTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginTop="14dp"
        android:layout_marginEnd="10dp"
        android:layout_marginBottom="15dp"
        tools:text="TextView"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

リストビューの設定

まずはAdapterを作成します。AdapterはBaseExpandableListAdapterクラスを継承して作成します

BaseExpandableListAdapterを継承すると次のようにoverrideしないといけない関数を次のように設定します

class ExpandableListAdapter(
    private val context: Context,
    var dataList: Map<String, List<String>>?
 ): BaseExpandableListAdapter() {
    //親要素の数を返す
    override fun getGroupCount(): Int {
        if (!dataList.isNullOrEmpty()) {
            return dataList!!.keys.size
        }
        return 0
    }

    //子要素の数を返す
    override fun getChildrenCount(p0: Int): Int {
        if (!dataList.isNullOrEmpty()) {
            val key = dataList!!.keys.elementAt(p0)
            val list = dataList!![key]
            if (!list.isNullOrEmpty()) {
                return list.size
            }
        }
        return 0
    }

    //親要素を取得
    override fun getGroup(p0: Int): Any {
        if (!dataList.isNullOrEmpty()) {
            return dataList!!.keys.elementAt(p0)
        }
        return ""
    }

    //子要素を取得
    override fun getChild(p0: Int, p1: Int): Any {
        if (!dataList.isNullOrEmpty()) {
            val key = dataList!!.keys.elementAt(p0)
            val list = dataList!![key]
            if (!list.isNullOrEmpty()) {
                return list[p1]
            }
        }
        return ""
    }

    //親要素の固有IDを返す
    override fun getGroupId(p0: Int): Long {
        return 0
    }

    //子要素の固有IDを返す(このサンプルでは固有IDは無いので0)
    override fun getChildId(p0: Int, p1: Int): Long {
        return 0
    }

    //固有IDを持つかどうかを返す(このサンプルでは持たないのでfalse)
    override fun hasStableIds(): Boolean {
        return false
    }

    //親要素のレイアウト生成
    override fun getGroupView(p0: Int, p1: Boolean, p2: View?, p3: ViewGroup?): View {
        var title = ""
        var convertView = p2
        if (!dataList.isNullOrEmpty()) {
            title = dataList!!.keys.elementAt(p0)
        }
        if (convertView == null) {
            val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
            convertView = layoutInflater.inflate(R.layout.expandable_cell, null)
        }

        val titleTextView = convertView!!.findViewById<TextView>(R.id.expandableTitleTextView)
        titleTextView.text = title

        return convertView
    }

    //子要素のレイアウト生成
    override fun getChildView(p0: Int, p1: Int, p2: Boolean, p3: View?, p4: ViewGroup?): View {
        var convertView = p3

        var title = ""
        val key = dataList!!.keys.elementAt(p0)
        val list = dataList!![key]
        if (!list.isNullOrEmpty()) {
            title = list[p1]
        }
        if (convertView == null) {
            val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
            convertView = layoutInflater.inflate(R.layout.expandable_cell, null)
        }

        val titleTextView = convertView!!.findViewById<TextView>(R.id.expandableTitleTextView)
        titleTextView.text = title

        return convertView
    }

    //子要素がタップ可能かどうかを返す(このサンプルでは可能にするのでtrue)
    override fun isChildSelectable(p0: Int, p1: Int): Boolean {
        return true
    }
}

dataListのkeyが親要素、valueが子要素になるようにしています

そして、adapterとExpandableListViewを紐づけます

val dictionary = mapOf("title1" to listOf("foo", "bar", "fizz", "buzz"), "title2" to listOf("fizz"))
adapter = ExpandableListAdapter(this, dictionary)
expandableList.setAdapter(adapter)

この状態で起動すると次のように開閉式メニューが実現できます

ただし、親要素の左側に開閉状態を表すマークが表示されています。 デフォルトで表示されるので表示した上で表示されるテキストの位置を変更するかマークを消すかどちらかで対応します

今回はマークを消す設定を追加します。 次のようにandroid:groupIndicator="@null"を追加することでマークを消すことができます

<ExpandableListView
        android:id="@+id/expandableList"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:groupIndicator="@null"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

あまり使うことのないレイアウトだと思いますが、ListViewを継承しているということを覚えておくと大まかに作成できると思います

参照

developer.android.com

www.fineblue206.net

stackoverflow.com

kamiya-kizuku.hatenablog.com