はじめまして。去年11月に入社したエンジニアの佐藤です。
現在は主に Android アプリエンジニアとして開発に携わっています。 自分のAndroidアプリに関しての未熟さ(Androidアプリ開発歴1.5年ほど)を痛感しながら、他のメンバーの皆さま方から学ぶことが多い日々です。
他の皆さんが書くような難しい内容は、わたしにはまだ書けないので、今回は最近担当した↓の実装について書こうと思います。
ユーザー様検索結果で各ユーザー様の「スキル/利用できるツールを表示する」というもので、これを赤枠のように表示する、という、よくあるハッシュタグ風な表示の実装です。
CSSですと、 display: flex;
などで表現するようなところだと思います。
Android アプリ
flexbox-layout というライブラリを使ったら簡単にできました。安心のGoogle 製ライブラリです。
サンプルアプリを作る形式で説明していこうと思います。
今回作ったサンプルのソースはこちら。
↓サンプルの作成手順
まず、Android Studioで、適当に空のActivityを持つプロジェクトを作成します。
build.gredle
dependencies
に、com.google.android:flexbox
を追加するのですが、
flexbox-layout の README にあるように、
- AndroidX を利用しているプロジェクトの場合
implementation 'com.google.android:flexbox:1.1.0'
- AndroidX を利用していないプロジェクトの場合
implementation 'com.google.android:flexbox:1.0.0'
を dependencies
に追加します。
drawable/bg_round_rectangle.xml
ハッシュタグ表示の背景用に drawable resource を追加しておきます。
<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android"> <solid android:color="#B38ec31f" /> <corners android:radius="8dp" /> </shape>
layout/activity_main.xml
MainActivityに固定のハッシュタグを表示する場合(実際はそういうケースは少ないと思いますが)は下記のようなlayoutを書きます(data binding 利用を前提にしています🙇♂️)。
<?xml version="1.0" encoding="utf-8" ?> <layout 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"> <ScrollView android:layout_width="match_parent" android:layout_height="match_parent"> <android.support.constraint.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" tools:context=".MainActivity"> <com.google.android.flexbox.FlexboxLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="12dp" app:alignContent="flex_start" app:alignItems="flex_start" app:flexWrap="wrap" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent"> <TextView android:id="@+id/txt1" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="4dp" android:background="@drawable/bg_round_rectangle" android:padding="4dp" android:text="こんにゃく" android:textColor="#FFFFFF" /> <TextView android:id="@+id/txt2" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="4dp" android:background="@drawable/bg_round_rectangle" android:padding="4dp" android:text="エリスリトール" android:textColor="#FFFFFF" /> <TextView android:id="@+id/txt3" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="4dp" android:background="@drawable/bg_round_rectangle" android:padding="4dp" android:text="大豆" android:textColor="#FFFFFF" /> <TextView android:id="@+id/txt4" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="4dp" android:background="@drawable/bg_round_rectangle" android:padding="4dp" android:text="大豆製品(豆腐, 湯葉, 油揚げ, 納豆など)" android:textColor="#FFFFFF" /> <TextView android:id="@+id/txt5" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="4dp" android:background="@drawable/bg_round_rectangle" android:padding="4dp" android:text="枝豆" android:textColor="#FFFFFF" /> <TextView android:id="@+id/txt6" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="4dp" android:background="@drawable/bg_round_rectangle" android:padding="4dp" android:text="無調整豆乳" android:textColor="#FFFFFF" /> <TextView android:id="@+id/txt7" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="4dp" android:background="@drawable/bg_round_rectangle" android:padding="4dp" android:text="アーモンド" android:textColor="#FFFFFF" /> </com.google.android.flexbox.FlexboxLayout> </android.support.constraint.ConstraintLayout> </ScrollView> </layout>
これだけでAndroid Studioで下記のような Preview が表示されていると思います。
FlexboxLayout
の属性で利用したもの(1つだけですが)について説明すると、
app:flexWrap
FlexboxLayout 内のView を
1行(nowrap
default)にするか、
複数行(wrap
)にするか、
複数行で逆順にするか(wrap_reverse
)を設定します。 今回の例では複数行(wrap
)にしています。
それ以外を設定した場合、下記のようになります。
app:flexWrap="nowrap"
1行に収める。
app:flexWrap="wrap_reverse"
行の順番が逆になる。
FlexboxLayout内を コードで 動的に追加する場合
下記のように書けます。
layout/activity_main.xml
<?xml version="1.0" encoding="utf-8" ?> <layout 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"> <ScrollView android:layout_width="match_parent" android:layout_height="match_parent"> <android.support.constraint.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content" tools:context=".MainActivity"> <com.google.android.flexbox.FlexboxLayout android:id="@+id/flex" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="12dp" app:flexWrap="wrap" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> </android.support.constraint.ConstraintLayout> </ScrollView> </layout>
layout/item_tag.xml
ハッシュタグ用のレイアウトを追加しておきます。
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content"> <TextView android:id="@+id/txt" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="4dp" android:background="@drawable/bg_round_rectangle" android:padding="4dp" android:textColor="#FFFFFF" tools:text="アーモンド" /> </LinearLayout> </layout>
MainActivity.kt
package com.example.flexbox.flexboxexample import com.example.flexbox.flexboxexample.databinding.ActivityMainBinding import android.databinding.DataBindingUtil import android.support.v7.app.AppCompatActivity import android.os.Bundle import android.view.LayoutInflater import com.example.flexbox.flexboxexample.databinding.ItemTagBinding class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main) // 糖質の少ない食品 val keywords = arrayListOf("こんにゃく", "エリスリトール", "大豆製品(豆腐, 湯葉, 油揚げ, 納豆など)", "枝豆", "無調整豆乳", "アーモンド", "杏仁", "カシューナッツ", "くるみ", "けし", "ごま", "ピスタチオ", "ピーナッツ", "マカダミアナッツ", "オクラ", "かぶ", "カリフラワー", "キャベツ", "キュウリ", "小松菜", "ごぼう", "大根", "タケノコ", "玉ねぎ", "チンゲン菜", "トマト", "なす", "にら", "にんじん", "にんにく", "ねぎ", "白菜", "パプリカ", "ピーマン", "ふき", "ブロッコリー", "ホウレン草", "もやし", "レタス", "アボカド", "きのこ類", "魚", "肉", "卵", "チーズ", "生クリーム", "バター", "牛乳", "ウイスキー", "ウォッカ", "焼酎", "ジン", "ラム", "ワイン(甘口は要注意)", "コーヒー", "紅茶", "日本茶", "ウーロン茶", "プーアル茶", "ジャスミン茶", "コーラゼロ", "こしょう", "塩", "しょうゆ", "酢", "白みそ以外のみそ", "マヨネーズ","こんにゃく", "エリスリトール", "大豆", "大豆製品(豆腐", "湯葉", "油揚げ", "納豆など)", "枝豆", "無調整豆乳", "アーモンド", "杏仁", "カシューナッツ", "くるみ", "けし", "ごま", "ピスタチオ", "ピーナッツ", "マカダミアナッツ", "オクラ", "かぶ", "カリフラワー", "キャベツ", "キュウリ", "小松菜", "ごぼう", "大根", "タケノコ", "玉ねぎ", "チンゲン菜", "トマト", "なす", "にら", "にんじん", "にんにく", "ねぎ", "白菜", "パプリカ", "ピーマン", "ふき", "ブロッコリー", "ホウレン草", "もやし", "レタス", "アボカド", "きのこ類", "魚", "肉", "卵", "チーズ", "生クリーム", "バター", "牛乳", "ウイスキー", "ウォッカ", "焼酎", "ジン", "ラム", "ワイン(甘口は要注意)", "コーヒー", "紅茶", "日本茶", "ウーロン茶", "プーアル茶", "ジャスミン茶", "コーラゼロ", "こしょう", "塩", "しょうゆ", "酢", "白みそ以外のみそ", "マヨネーズ","こんにゃく", "エリスリトール", "大豆", "大豆製品(豆腐", "湯葉", "油揚げ", "納豆など)", "枝豆", "無調整豆乳", "アーモンド", "杏仁", "カシューナッツ", "くるみ", "けし", "ごま", "ピスタチオ", "ピーナッツ", "マカダミアナッツ") keywords.forEach { val itemTagBinding = DataBindingUtil.inflate<ItemTagBinding>(LayoutInflater.from(this), R.layout.item_tag, binding.flex, true) itemTagBinding.txt.text = it } } }
実動作イメージ
iOS アプリ
※ココナラアプリのiOS版のハッシュタグ表示はわたしが担当したわけではないです。すいません🙇♂️
iOSでハッシュタグ風UIを実現するのに手頃なオープンソースライブラリは、ありそうではありましたが、普通に標準ライブラリのUICollectionView関連の実装で書けそうでした。
今回作ったサンプルのソースはこちら。
GitHub - HikaruSato/KeywordCollectionViewFlowLayout: ハッシュタグ風UIのExample。iOS版
↓サンプルの作成手順
まず適当に空のプロジェクトを Xcodeで作成します。
Main.storyboard
ストーリーボードのViewController の下に CollectionViewを追加して、cellにLabelを追加します。
また、UICollectionViewFlowLayouのClass欄にはカスタマイズするため、KeywordCollectionViewFlowLayout
(下記で定義するクラス)を記載しておきます。
ViewController.swift
import UIKit class ViewController: UICollectionViewController{ fileprivate let cellIdentifier = "cell" let labelOuterMargin: CGFloat = 5 let labelInsetMargin: CGFloat = 10 var cellAmimationStatusDic: [Int : Bool] = [:] // 糖質の少ない食品 let keywords = ["こんにゃく", "エリスリトール", "大豆製品(豆腐, 湯葉, 油揚げ, 納豆など)", "枝豆", "無調整豆乳", "アーモンド", "杏仁", "カシューナッツ", "くるみ", "けし", "ごま", "ピスタチオ", "ピーナッツ", "マカダミアナッツ", "オクラ", "かぶ", "カリフラワー", "キャベツ", "キュウリ", "小松菜", "ごぼう", "大根", "タケノコ", "玉ねぎ", "チンゲン菜", "トマト", "なす", "にら", "にんじん", "にんにく", "ねぎ", "白菜", "パプリカ", "ピーマン", "ふき", "ブロッコリー", "ホウレン草", "もやし", "レタス", "アボカド", "きのこ類", "魚", "肉", "卵", "チーズ", "生クリーム", "バター", "牛乳", "ウイスキー", "ウォッカ", "焼酎", "ジン", "ラム", "ワイン(甘口は要注意)", "コーヒー", "紅茶", "日本茶", "ウーロン茶", "プーアル茶", "ジャスミン茶", "コーラゼロ", "こしょう", "塩", "しょうゆ", "酢", "白みそ以外のみそ", "マヨネーズ","こんにゃく", "エリスリトール", "大豆", "大豆製品(豆腐", "湯葉", "油揚げ", "納豆など)", "枝豆", "無調整豆乳", "アーモンド", "杏仁", "カシューナッツ", "くるみ", "けし", "ごま", "ピスタチオ", "ピーナッツ", "マカダミアナッツ", "オクラ", "かぶ", "カリフラワー", "キャベツ", "キュウリ", "小松菜", "ごぼう", "大根", "タケノコ", "玉ねぎ", "チンゲン菜", "トマト", "なす", "にら", "にんじん", "にんにく", "ねぎ", "白菜", "パプリカ", "ピーマン", "ふき", "ブロッコリー", "ホウレン草", "もやし", "レタス", "アボカド", "きのこ類", "魚", "肉", "卵", "チーズ", "生クリーム", "バター", "牛乳", "ウイスキー", "ウォッカ", "焼酎", "ジン", "ラム", "ワイン(甘口は要注意)", "コーヒー", "紅茶", "日本茶", "ウーロン茶", "プーアル茶", "ジャスミン茶", "コーラゼロ", "こしょう", "塩", "しょうゆ", "酢", "白みそ以外のみそ", "マヨネーズ","こんにゃく", "エリスリトール", "大豆", "大豆製品(豆腐", "湯葉", "油揚げ", "納豆など)", "枝豆", "無調整豆乳", "アーモンド", "杏仁", "カシューナッツ", "くるみ", "けし", "ごま", "ピスタチオ", "ピーナッツ", "マカダミアナッツ", "オクラ", "かぶ", "カリフラワー", "キャベツ", "キュウリ", "小松菜", "ごぼう", "大根", "タケノコ", "玉ねぎ", "チンゲン菜", "トマト", "なす", "にら", "にんじん", "にんにく", "ねぎ", "白菜", "パプリカ", "ピーマン", "ふき", "ブロッコリー", "ホウレン草", "もやし", "レタス", "アボカド", "きのこ類", "魚", "肉", "卵", "チーズ", "生クリーム", "バター", "牛乳", "ウイスキー", "ウォッカ", "焼酎", "ジン", "ラム", "ワイ<e3><83><b3>(甘口は要注意)", "コーヒー", "紅茶", "日本茶", "ウーロン茶", "プーアル茶", "ジャスミン茶", "コーラゼロ", "こしょう", "塩", "しょうゆ", "酢", "白みそ以外のみそ", "マヨネーズ","こんにゃく", "エリスリトール", "大豆", "大豆製品(豆腐", "湯葉", "油揚げ", "納豆など)", "枝豆", "無調整豆乳", "アーモンド", "杏仁", "カシューナッツ", "くるみ", "けし", "ごま", "ピスタチオ", "ピーナッツ", "マカダミアナッツ", "オクラ", "かぶ", "カリフラワー", "キャベツ", "キュウリ", "小松菜", "ごぼう", "大根", "タケノコ", "玉ねぎ", "チンゲン菜", "トマト", "なす", "にら", "にんじん", "にんにく", "ねぎ", "白菜", "パプリカ", "ピーマン", "ふき", "ブロッコリー", "ホウレン草", "もやし", "レタス", "アボカド", "きのこ類", "魚", "肉", "卵", "チーズ", "生クリーム", "バター", "牛乳", "ウイスキー", "ウォッカ", "焼酎", "ジン", "ラム", "ワイン(甘口は要注意)", "コーヒー", "紅茶", "日本茶", "ウーロン茶", "プーアル茶", "ジャスミン茶", "コーラゼロ", "こしょう", "塩", "しょうゆ", "酢", "白みそ以外のみそ", "マヨネーズ","こんにゃく", "エリスリトール", "大豆", "大豆製品(豆腐", "湯葉", "油揚げ", "納豆など)", "枝豆", "無調整豆乳", "アーモンド", "杏仁", "カシューナッツ", "くるみ", "けし", "ごま", "ピスタチオ", "ピーナッツ", "マカダミアナッツ", "オクラ", "かぶ", "カリフラワー", "キャベツ", "キュウリ", "小松菜", "ごぼう", "大根", "タケノコ", "玉ねぎ", "チンゲン菜", "トマト", "なす", "にら", "にんじん", "にんにく", "ねぎ", "白菜", "パプリカ", "ピーマン", "ふき", "ブロッコリー", "ホウレン草", "もやし", "レタス", "アボカド", "きのこ類", "魚", "肉", "卵", "チーズ", "生クリーム", "バター", "牛乳", "ウイスキー", "ウォッカ", "焼酎", "ジン", "ラム", "ワイン(甘口は要注意)", "コーヒー", "紅茶", "日本茶", "ウーロン茶", "プーアル茶", "ジャスミン茶", "コーラゼロ", "こしょう", "塩", "しょうゆ", "酢", "白みそ以外のみそ", "マヨネーズ"] override func viewDidLoad() { super.viewDidLoad() if let layout = self.collectionView?.collectionViewLayout as? KeywordCollectionViewFlowLayout { layout.sectionInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) layout.minimumLineSpacing = 8 layout.minimumInteritemSpacing = 8 layout.delegate = self } } override var prefersStatusBarHidden : Bool { return true } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() } override func numberOfSections(in collectionView: UICollectionView) -> Int { return 1 } override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return keywords.count } override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) let mainLabel = cell.viewWithTag(1) as! UILabel mainLabel.text = keywords[indexPath.row] mainLabel.sizeToFit() mainLabel.frame.size = CGSize(width: getLabelWidth(label: mainLabel), height: 40) mainLabel.layer.cornerRadius = 5 mainLabel.layer.masksToBounds = true // 最初の描画時にアニメーションしてみる if !cellAmimationStatusDic.contains(where: { (key, value) -> Bool in key == indexPath.row }) { cellAmimationStatusDic[indexPath.row] = true let orgFrame = cell.frame let random = arc4random() % 10 let delay = TimeInterval(random) / 10 cell.frame = CGRect(x: cell.frame.origin.x, y: cell.frame.origin.y + UIScreen.main.bounds.height, width: cell.frame.width, height: cell.frame.height) UIView.animate(withDuration: 1.3, delay: delay, usingSpringWithDamping: 0.9, initialSpringVelocity: 0.0, options: UIView.AnimationOptions.curveEaseInOut, animations: { cell.frame = orgFrame }, completion: nil) } return cell } fileprivate func getLabelWidth(label:UILabel) -> CGFloat { return label.frame.width + labelInsetMargin * 2 } } extension ViewController:KeywordCollectionViewFlowLayoutDelegate { func collectionView(collectionView:UICollectionView, widthAtIndexPath indexPath:IndexPath) -> CGFloat { let label = UILabel() label.font = UIFont.systemFont(ofSize: 15) label.text = keywords[indexPath.row] label.sizeToFit() return getLabelWidth(label: label) + labelOuterMargin * 2 } }
KeywordCollectionViewFlowLayout.swift
import UIKit protocol KeywordCollectionViewFlowLayoutDelegate { func collectionView(collectionView:UICollectionView, widthAtIndexPath indexPath:IndexPath) -> CGFloat } open class KeywordCollectionViewFlowLayout: UICollectionViewFlowLayout { var delegate: KeywordCollectionViewFlowLayoutDelegate! fileprivate var sectionCellRects = [[CGRect]]() fileprivate var contentSize = CGSize.zero override open func prepare() { super.prepare() if (!sectionCellRects.isEmpty) { return } guard let collectionView = self.collectionView else { return } let cellHeight: CGFloat = itemSize.height let maxWidth = collectionView.bounds.width - collectionView.contentInset.left - collectionView.contentInset.right contentSize = CGSize(width: 0, height: 0) var xOffset: CGFloat = super.sectionInset.left var yOffset: CGFloat = super.sectionInset.top for section in (0..<collectionView.numberOfSections) { var cells = [CGRect]() let numberOfCellsInSection = collectionView.numberOfItems(inSection: section) var height = contentSize.height for i in (0..<numberOfCellsInSection) { let cellwidth = delegate.collectionView(collectionView: collectionView, widthAtIndexPath: IndexPath(row: i, section: section)) var x = xOffset if x + cellwidth + super.minimumInteritemSpacing > maxWidth { //New Line x = super.sectionInset.left yOffset += cellHeight + super.minimumLineSpacing xOffset = x + cellwidth + minimumInteritemSpacing } else { xOffset += cellwidth + super.minimumInteritemSpacing } let y = yOffset let cellRect = CGRect(x: x, y: y, width: cellwidth, height: cellHeight) cells.append(cellRect) if (height < cellRect.origin.y + cellRect.height) { height = cellRect.origin.y + cellRect.height } } height += cellHeight + super.minimumLineSpacing contentSize = CGSize(width: maxWidth, height: height) sectionCellRects.append(cells) } } override open func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { super.layoutAttributesForElements(in: rect) var layoutAttributes = [UICollectionViewLayoutAttributes]() if let collectionView = self.collectionView { for i in 0 ..< collectionView.numberOfSections { let numberOfCellsInSection = collectionView.numberOfItems(inSection: i) for j in 0 ..< numberOfCellsInSection { let indexPath = IndexPath(row:j, section:i) if let attributes = layoutAttributesForItem(at: indexPath) { if (rect.intersects(attributes.frame)) { layoutAttributes.append(attributes) } } } } } return layoutAttributes } override open func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { let attributes = super.layoutAttributesForItem(at: indexPath) attributes?.frame = sectionCellRects[indexPath.section][indexPath.row] return attributes } override open var collectionViewContentSize : CGSize { return contentSize } }
実動作イメージ
まとめ
AndroidアプリとiOSアプリは似たようなイメージですが、実装は結構違ったりすると思います(何を実装するかにもよりますが)。
ちなみにココナラでは一緒にココナラを改善してくれる仲間も募集しております。