- 投稿日:2020-05-16T21:29:14+09:00
UIButtonをimageをセットしたが表示されない件
UIButtonにsetImage()したけど画像の色が違う
UIButtonを画像にしたい+
.normal
と.selected
で画像を切り替えたいと思った時に躓きました。● 実装(Rx使ってます)
private func setButton() { button.setImage(R.image.grayCat(), for: .normal) button.setImage(R.image.purpleCat(), for: .selected) button.rx.tap.bind(to: buttonTapBinder).disposed(by: disposeBag) } private var buttonTapBinder: Binder<()> { return Binder(self) { base, _ in base.button.isSelected = !base.button.isSelected } }● 使った画像
実行画面
ボタンタップしたレイアウトもちがーーーーーーーーーーーう!!!!
解決策
私は普段
.xib
ファイルで部品の配置とAutlayoutの設定をしているのですが、その際のButtonType
が原因でした。
これをCustom
にすれば解決です!この
ButtonType
はget-only
なのでコードからは変更できません。
ちなみにレイアウト系もコードで実装している場合は、初期値がCustom
なので特に問題はないです。
- 投稿日:2020-05-16T21:29:14+09:00
【Swift】UIButtonをimageをセットしたが表示されない件
UIButtonにsetImage()したけど画像の色が違う
UIButtonを画像にしたい+
.normal
と.selected
で画像を切り替えたいと思った時に躓きました。● 実装(Rx使ってます)
private func setButton() { button.setImage(R.image.grayCat(), for: .normal) button.setImage(R.image.purpleCat(), for: .selected) button.rx.tap.bind(to: buttonTapBinder).disposed(by: disposeBag) } private var buttonTapBinder: Binder<()> { return Binder(self) { base, _ in base.button.isSelected = !base.button.isSelected } }● 使った画像
実行画面
ボタンタップしたレイアウトもちがーーーーーーーーーーーう!!!!
解決策
私は普段
.xib
ファイルで部品の配置とAutlayoutの設定をしているのですが、その際のButtonType
が原因でした。
これをCustom
にすれば解決です!この
ButtonType
はget-only
なのでコードからは変更できません。
ちなみにレイアウト系もコードで実装している場合は、初期値がCustom
なので特に問題はないです。
- 投稿日:2020-05-16T21:21:49+09:00
iOSリバーシリファクタリングチャレンジ w/ Redux
リバーシリファクタリングチャレンジ
koherさんが公開された、このFat View Controller、あなたはリファクタリングできますか?チャレンジに参加しました。
本チャレンジは、 Fat View Controller として実装されたリバーシアプリをリファクタリングし、どれだけクリーンな設計とコードを実現できるかというコンペティションです(ジャッジが優劣を判定するわけではなく、設計の技を競い合うのが目的です)。
すばらしいチャレンジを用意くださったkoherさんを始め、運営のお手伝いをされているtakasekさん、Ogawaさんの皆様に感謝です。
リファクタリング結果
以下のGitHubリポジトリにリファクタリングした結果を公開しています。masterブランチがリファクタリング済みになります。
この記事にもコードを記載していますが全体の8割ぐらいです。リファクタリング方針
ゼロから作り直すやり方ではなく、オリジナルのリバーシ部分のロジックを踏襲しつつ、少しずつ動作を確認しながらリファクタリングを実施しました。
iOSアプリ設計パターン入門にて、Reduxアーキテクチャの章を執筆したので、Reduxアーキテクチャを適用したリファクタリングにチャレンジします。ターン制のゲームはユーザーやコンピュータのアクションによりゲームの状態が変化し、状態の変化に合わせたビューの表示を行う流れは、Reduxアーキテクチャと相性が良いと感じました。
リファクタリング観点
以下のような観点を意識しながら、リファクタリングを行いました。
ただ、各観点を最大化することが目的ではなく、適切な範囲にとどめ全体最適を目指すことが大事だと思っています。
- 階層、分断、排他、網羅を意識した構造化
- 影響範囲の局所化
- 型表現や命名による意味付け
- 単一責務化
- 制約化
- 抽象化
- 共通化
アーキテクチャ導入
Reduxアーキテクチャ構造
リバーシアプリに適用したReduxアーキテクチャの構造は以下の図のようになります。
また、Reduxアーキテクチャを支援するReSwiftライブラリを導入しました。Reduxは状態の変化を単一のフロー制御により制約付けられており、ViewからはActionをStoreにdispatchし、Reducer関数によってStateが変更されます。ViewはStoreから変更されたことが通知されるので、イミュータブルなStateにアクセスし前回のStateから変化した状態を割り出して適切なViewの表示を更新したり、Stateの状態を鑑みて新たなActionをStoreにdispatchしたりします。
レイヤ分割(Xcodeターゲット分割)
Reduxアーキテクチャを導入するにあたりターゲットを以下の2つに分割しました。
もっと規模が大きいアプリの場合は、LogicレイヤをCleanアーキテクチャを参考にレイヤ分割してもよいかもしれません。
- Reversiターゲット(ビューに関するコード)
- UIKit ViewController
- UIKit View
- Logicターゲット(ロジックに関するコード)
- Redux actions
- Redux store
- Redux state
- Redux reducer
依存関係は以下のとおりです。
- Reversiターゲット ⇒ Logicターゲット
Logicターゲットは以下のような目的で設けました。
- Viewのコードに依存しないビジネスロジックのコードを局所化するため
- UIKitに依存しないPureSwiftのコードを局所化するため
- ビジネスロジックのコードのViewに対する可視性を細かく制御(publicとinternalの使い分け)したかったため
可視性制御(publicとinternalの使い分け)
- stateやdataのstruct/classはロジック側でのみinitできるように制約する
- ⇒ View側で意図せず状態が生成され利用されることを抑止する
- internalなプロパティはテストコードからでも参照できるので、より詳細な内部の状態の確認に利用する
DataTypeの抽出
- 責務過多なデータ表現は用途を限定した単一責務のデータに分割する
- e.g.
Disk
andSide
- 役割を担うプリミティブなデータ構造をデータ型と命名を与え意味付する
- e.g.
Coordinate
,PlacedDiskCoordinate
,OptionalDiskCoordinate
- 有限なデータを可変長[配列]で表現している箇所は有限で表現する
- ビューのインスタンスが保持しているデータをDataTypeとして抽出する
既存のDataType
public enum Player: Int, Equatable, Codable { case manual = 0 case computer = 1 } public enum Disk: String, Codable { case diskDark case diskLight }追加したDataType
public enum Side: String, Codable, CaseIterable { case sideDark case sideLight } extension Side: Hashable {} extension Side { public var index: Int { switch self { case .sideDark: return 0 case .sideLight: return 1 } } public var disk: Disk { switch self { case .sideDark: return .diskDark case .sideLight: return .diskLight } } } extension Side { var flipped: Side { switch self { case .sideDark: return .sideLight case .sideLight: return .sideDark } } } public struct Coordinate: Equatable, Codable { public var x: Int public var y: Int public init(x: Int, y: Int) { self.x = x self.y = y } } infix operator +: AdditionPrecedence extension Coordinate { static func + (left: Coordinate, right: Coordinate) -> Coordinate { return Coordinate(x: left.x + right.x, y: left.y + right.y) } } public struct PlacedDiskCoordinate: Equatable, Codable { public var disk: Disk public var coordinate: Coordinate public init(disk: Disk, coordinate: Coordinate) { self.disk = disk self.coordinate = coordinate } } extension PlacedDiskCoordinate { var optionalDiskCoordinate: OptionalDiskCoordinate { OptionalDiskCoordinate(disk: disk, coordinate: coordinate) } } public struct OptionalDiskCoordinate: Equatable, Codable { public var disk: Disk? public var coordinate: Coordinate } public struct BoardSetting: Equatable, Codable { public var cols: Int public var rows: Int private var xRange: Range<Int> { 0 ..< self.cols } private var yRange: Range<Int> { 0 ..< self.rows } public var coordinates: [Coordinate] { self.yRange.map { y in self.xRange.map { x in Coordinate(x: x, y: y) } }.flatMap { $0 } } public func validCoordinate(_ coordinate: Coordinate) -> Bool { self.xRange.contains(coordinate.x) && self.yRange.contains(coordinate.y) } init(cols: Int, rows: Int) { self.cols = cols self.rows = rows } }補足
データが
Equatable
に準拠しているのはView側でStateの変更通知を受け取り、Stateへアクセスしたときに前回の変更通知から、どこが変わったのかわからないため前回のStateと比較して差分を検知するために利用します。GameProgress(ゲーム進行状態表現)
ゲームの進行状態を表現する
GameProgress
を導入しました。
- このゲームの進行状態は有限でかつ排他的なのでenumで表現
- 各進行状態下に付随する取りうる状態をassociated valueを活用して包括関係を表現
public enum GameProgress: Equatable { case initialing case turn(Progress, Side, Player, ComputerThinking) case gameOver(GameOver) case interrupt(Interrupt) } public enum Side: String, Codable, CaseIterable { case sideDark case sideLight } public enum ComputerThinking: String, Equatable, Codable { case none case thinking } public enum GameOver: Equatable { case won(Side) case tied } public enum Interrupt: Equatable { case resetConfrmation(Alert) case cannotPlaceDisk(Alert) } public enum Alert: String, Equatable, Codable { case none case shouldShow case showing } public enum Progress: Equatable { case start case progressing }以下の表は
GameProgress
が取りうる主要な状態のパターンを表しています。
(注意:この表は便宜上のため、正しい包括関係と網羅性を担保できていません)
GameProgress Associated value1 Associated value2 Associated value3 initialing --- --- --- turn Side.dark Player.manual ComputerThinking.none 〃 〃 Player.computer ComputerThinking.none 〃 〃 〃 ComputerThinking.thinking 〃 Side.light Player.manual ComputerThinking.none 〃 〃 Player.computer ComputerThinking.none 〃 〃 〃 ComputerThinking.thinking gameOver GameOver.won Side.dark --- 〃 〃 Side.light --- 〃 GameOver.tied --- --- interrupt resetConfirmation Alert.none --- 〃 〃 Alert.shouldShow --- 〃 〃 Alert.showing --- 〃 cannotPlaceDisk Alert.none --- 〃 〃 Alert.shouldShow --- 〃 〃 Alert.showing --- interrup(割り込み状態表現)
アラートを表示中の状態管理が悩ましかったので
interrupt
状態を導入しゲーム中への割り込みを表現しています。
既存コードでは、リセットのアラートを表示中でも双方のプレイヤーがコンピュータであればゲームは進行します。
このとき、プレイヤーが手詰まりになると、手詰まりのアラートを表示するのですが、2つのアラート表示がバッティングするので対処が必要になります。本リファクタリングでは、リセットアラートを表示中はコンピュータによるゲームを進行しない方針としました。
interrupt
状態はturn
状態と排他的な状態で、ゲームの進行(コンピュータの思考)はturn
状態のみ実行できるものとすることで実現しました。また、
interrupt
は手詰まりのアラートをシステムから表示する場合も割り込みとして表現しました。
既存コード リファクタリングコード リセットアラートが表示中でもゲームが進行する リセットアラートが表示中だとゲームが進行しない Redux
AppStateとAcrtionのすべてのコードはGitHub repositoryで確認できます。
AppState
AppState
はViewから参照される状態の起点になるStateです。
GameProgress
はAppState
の内部状態から現在あるべきGameProgress
の状態をComputed propertyを用いて割り出しています。import Foundation import ReSwift public struct AppState: StateType, Codable { public var boardContainer: BoardContainer public var playerDark: PlayerSide = .init(side: .sideDark) public var playerLight: PlayerSide = .init(side: .sideLight) public var gameProgress: GameProgress { if isInitialing { return .initialing } else if cannotPlaceDiskAlert != .none { return .interrupt(.cannotPlaceDisk(cannotPlaceDiskAlert)) } else if resetConfirmationAlert != .none { return .interrupt(.resetConfirmation(resetConfirmationAlert)) } else if let side = side { let progress: Progress = turnStart ? .start : .progressing let player: Player switch side { case .sideDark: player = playerDark.player case .sideLight: player = playerLight.player } return .turn(progress, side, player, computerThinking) } else if let winnerSide = boardContainer.board.sideWithMoreDisks() { return .gameOver(.won(winnerSide)) } else { return .gameOver(.tied) } } var id: String = NSUUID().uuidString // prevent override uing reseted state var side: Side? = .sideDark var turnStart: Bool = false var isInitialing: Bool = true var isLoadedGame: Bool = false // prevent duplicate load game calls var computerThinking: ComputerThinking = .none var cannotPlaceDiskAlert: Alert = .none var resetConfirmationAlert: Alert = .none init(boardSetting: BoardSetting = .init(cols: 8, rows: 8)) { self.boardContainer = .init(diskCoordinatesState: Board(boardSetting: boardSetting)) } }Reducer
Reducer
はミューテーション可能なStateのコピーを一時的に生成し、新たな状態を反映させたうえ、イミュータブルなStateとして返す純粋関数です。
各Actionによって、どのように状態が変化するのか一目瞭然となっています。func reducer(action: Action, state: AppState?) -> AppState { var state = state ?? .init() if state.turnStart { state.turnStart = false } if let action = action as? AppAction { switch action { case .startGame: state.isInitialing = false case .placeDisk(let placedDiskCoordinate): let flippedDiskCoordinates = state.boardContainer.board.flippedDiskCoordinatesByPlacingDisk(placedDiskCoordinate) guard !flippedDiskCoordinates.isEmpty else { return state } let changed: BoardChanged = .init(placedDiskCoordinate: placedDiskCoordinate, flippedDiskCoordinates: flippedDiskCoordinates) changed.changedDiskCoordinate.forEach { state.boardContainer.board[$0.coordinate] = $0.optionalDiskCoordinate } state.boardContainer.changed = changed state.playerDark.count = state.boardContainer.board.count(of: .diskDark) state.playerLight.count = state.boardContainer.board.count(of: .diskLight) case .cannotPlaceDisk(let alert): state.cannotPlaceDiskAlert = alert case .resetConfirmation(let alert): state.resetConfirmationAlert = alert } } if let action = action as? AppPrivateAction { switch action { case .nextTurn: guard case .none = state.resetConfirmationAlert else { return state } guard let temp = state.side else { assertionFailure(); return state } state.cannotPlaceDiskAlert = .none let side = temp.flipped state.side = side case .validateTurn: guard let side = state.side else { return state } if state.boardContainer.board.validMoves(for: side).isEmpty { if state.boardContainer.board.validMoves(for: side.flipped).isEmpty { state.side = nil // GameOver } else { state.cannotPlaceDiskAlert = .shouldShow } } else { state.turnStart = true } case .changePlayer(let side, let player): switch side { case .sideDark: state.playerDark.player = player case .sideLight: state.playerLight.player = player } state.turnStart = true if side == state.side { state.computerThinking = .none } case .resetAllState: var newState = AppState() newState.playerDark = .init(side: .sideDark, count: newState.boardContainer.board.count(of: .diskDark)) newState.playerLight = .init(side: .sideLight, count: newState.boardContainer.board.count(of: .diskLight)) return newState case .finisedLoadGame(let loadedAppState): return loadedAppState case .finisedSaveGame: break case .startComputerThinking: state.computerThinking = .thinking case .endComputerThinking: state.computerThinking = .none } } return state }Action
Action
はenumで表現して、Reducerで処理を行うべき網羅性を担保しています。
PrivateAction
は、Viewからdispatchしない、ActionCreator
からのみdisptachするAction
として設けています。public enum AppAction: Action { case startGame case placeDisk(PlacedDiskCoordinate) case cannotPlaceDisk(Alert) case resetConfirmation(Alert) } enum AppPrivateAction: Action { case nextTurn case validateTurn case changePlayer(side: Side, player: Player) case resetAllState case finisedLoadGame(AppState) case startComputerThinking case endComputerThinking case finisedSaveGame } struct ErrorAction: Action { let error: Error let title: String let message: String }ActionCreator
ActionCreator
はReducer
のようにStateの変更を行いませんが、以下のようなロジックの処理を担っています。
- 副作用(ゲームデータの保存・読み込み)が発生する処理
- 条件によってdispatchしたいActionを変更したい処理
- 複数のActionをdispatchしたい処理
また、副作用を伴う処理に依存する部分は、テスタブルにするためDependency Injectionしています。
extension AppAction { public static func newGame() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.newGame() START") dispatch(AppPrivateAction.resetAllState) dispatch(AppAction.saveGame()) print("- Logic.AppAction.newGame() END") } } public static func saveGame() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.saveGame() START") do { guard var state = getState() else { preconditionFailure() } state.isInitialing = true state.boardContainer.changed = nil state.computerThinking = .none state.resetConfirmationAlert = .none try dependency.persistentInteractor.saveGame(state) dispatch(AppPrivateAction.finisedSaveGame) } catch let error { dump(error) let title = "Error occurred." let message = "Cannot save games." dispatch(ErrorAction(error: error, title: title, message: message)) } print("- Logic.AppAction.saveGame() END") } } public static func loadGame() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.loadGame() START") do { guard let state = getState() else { preconditionFailure() } guard state.isLoadedGame == false else { return } // prevent duplicate load game calls dispatch(AppPrivateAction.resetAllState) let loadData = try dependency.persistentInteractor.loadGame() dispatch(AppPrivateAction.finisedLoadGame(loadData)) dispatch(AppPrivateAction.validateTurn) } catch let error { dump(error) dispatch(AppAction.newGame()) } print("- Logic.AppAction.loadGame() END") } } public static func nextTurn() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in guard let state = getState() else { preconditionFailure() } if case .turn(_, let side, _, _) = state.gameProgress { print("- Logic.AppAction.nextTurn() from: \(side) to: \(side.flipped)") } dispatch(AppPrivateAction.nextTurn) dispatch(AppPrivateAction.validateTurn) } } public static func changePlayer(side: Side, player: Player) -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.changePlayer(side: \(side), player: \(player)) START") dispatch(AppPrivateAction.changePlayer(side: side, player: player)) dispatch(AppAction.saveGame()) print("- Logic.AppAction.changePlayer(side: \(side), player: \(player)) END") } } public static func waitForPlayer() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.waitForPlayer() START") guard let state = getState() else { preconditionFailure() } switch state.gameProgress { case .turn(_, _, let player, _): switch player { case .manual: break case .computer: dispatch(AppAction.playTurnOfComputer()) } case .initialing, .interrupt, .gameOver: assertionFailure() } print("- Logic.AppAction.waitForPlayer() END") } } private static func playTurnOfComputer() -> Thunk<AppState> { return Thunk<AppState> { dispatch, getState, dependency in print("- Logic.AppAction.playTurnOfComputer() START") guard let state = getState() else { preconditionFailure() } switch state.gameProgress { case .turn(_, let side, _, _): let candidates = state.boardContainer.board.validMoves(for: side) switch candidates.isEmpty { case true: dispatch(AppAction.nextTurn()) case false: guard let candidate = candidates.randomElement() else { preconditionFailure() } let id = state.id store.dispatch(AppPrivateAction.startComputerThinking) DispatchQueue.main.asyncAfter(deadline: .now() + dependency.computerThinkingTime) { guard let state = getState() else { preconditionFailure() } guard id == state.id else { return } // maybe reset game guard case .turn(_, let sideEnd, _, let computerThinkingEnd) = state.gameProgress else { return } guard case .thinking = computerThinkingEnd, side == sideEnd else { return } // maybe chaned to manual player guard case .none = state.resetConfirmationAlert else { return } dispatch(AppPrivateAction.endComputerThinking) dispatch(AppAction.placeDisk(candidate)) } } case .initialing, .interrupt, .gameOver: assertionFailure() } print("- Logic.AppAction.playTurnOfComputer() END") } } }Dependency & Middleware
PersistentInteractor
,Repository
はDependency Injectionできようにすために、protocolで抽象化し、createThunkMiddleware
経由でDIしています。public let store = Store<AppState>( reducer: reducer, state: AppState(), middleware: [thunkMiddleware, loggingMiddleware] ) protocol Dependency { var persistentInteractor: PersistentInteractor { get } var computerThinkingTime: DispatchTimeInterval { get } } struct DependencyImpl: Dependency { let persistentInteractor: PersistentInteractor let computerThinkingTime: DispatchTimeInterval init(persistentInteractor: PersistentInteractor = PersistentInteractorImpl(), computerThinkingTime: DispatchTimeInterval = DispatchTimeInterval.milliseconds(1000)) { self.persistentInteractor = persistentInteractor self.computerThinkingTime = computerThinkingTime } } let thunkMiddleware: Middleware<AppState> = createThunkMiddleware() public struct Thunk<State>: Action { let body: (_ dispatch: @escaping DispatchFunction, _ getState: @escaping () -> State?, _ dependency: Dependency) -> Void init(body: @escaping ( _ dispatch: @escaping DispatchFunction, _ getState: @escaping () -> State?, _ dependency: Dependency) -> Void) { self.body = body } } func createThunkMiddleware<State>(dependency: Dependency = DependencyImpl()) -> Middleware<State> { return { dispatch, getState in return { next in return { action in switch action { case let thunk as Thunk<State>: thunk.body(dispatch, getState, dependency) default: next(action) } } } } } let loggingMiddleware: Middleware<AppState> = { dispatch, getState in return { next in return { action in dump(action) if case AppPrivateAction.validateTurn = action { print(getState()?.boardContainer.board.debugDescription ?? "N/A") } return next(action) } } }PersistentInteractor & Repository
ゲーム状態をファイルに保存・読み込みするためのロジックです。
既存では独自のファイルファーマット形式で保存していましたが、ReduxのStateをCodableに準拠してJSON形式でStateを丸ごと保存するようにしました。メリット
- 独自のファイルファーマット形式のパースロジックを排除し、一般的でかつパースAPIが提供されているJSONを利用できた
デメリット
- Stateの構造を変更すると、下位互換がなくなってしまいデータをロードできなくなってしまった
結論
- 過剰なリファクタリングでした
protocol PersistentInteractor { func saveGame(_ appState: AppState) throws /* PersistentError */ func loadGame() throws -> AppState /* PersistentError */ } struct PersistentInteractorImpl: PersistentInteractor { enum PersistentError: Error { case write(cause: Error?) case read(cause: Error?) } private let repository: Repository init(repository: Repository = RepositoryImpl()) { self.repository = repository } func encode(_ appState: AppState) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted return try encoder.encode(appState) } func saveGame(_ appState: AppState) throws { do { let data = try encode(appState) try repository.saveData(data) } catch let error { throw PersistentError.read(cause: error) } } func loadGame() throws -> AppState { do { let data = try repository.loadData() return try JSONDecoder().decode(AppState.self, from: data) } catch let error { throw PersistentError.write(cause: error) } } } extension Coordinate { /* Codable */ enum CodingKeys: String, CodingKey { case x case y } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(x, forKey: .x) try container.encode(y, forKey: .y) } }ファイルに保存するときのファイル名はコンストラクタインジェクションできるようにし、テスト時は変更できるようにしました。
アプリ実行時とテスト実行時で保存したファイルを競合しないようにするためです。protocol Repository { func saveData(_ data: Data) throws /* FileIOError */ func loadData() throws -> Data /* FileIOError */ func clear() throws /* FileIOError */ } struct RepositoryImpl: Repository { enum FileIOError: Error { case write(cause: Error?) case read(cause: Error?) case clear(cause: Error?) } let fileName: String init(fileName: String = "appstate.json") { self.fileName = fileName } private func createFileURL() throws -> URL { try FileManager.default .url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) .appendingPathComponent(fileName) } func saveData(_ data: Data) throws { do { let fileURL = try createFileURL() try data.write(to: fileURL, options: []) } catch let error { throw FileIOError.read(cause: error) } } func loadData() throws -> Data { do { let fileURL = try createFileURL() return try Data(contentsOf: fileURL) } catch let error { throw FileIOError.write(cause: error) } } func clear() throws { do { let fileURL = try createFileURL() try FileManager.default.removeItem(at: fileURL) } catch let error { throw FileIOError.clear(cause: error) } } }ViewController
ViewControllerのすべてのコードはGitHub repositoryで確認できます。
ViewControllerのイニシャライザを利用してReduxのStreをコンストラクタインジェクションしたかっったので、storyboardからxibに変更しました。
class ViewController: UIViewController, StoreSubscriber { ... private let store: Store<AppState> init(store: Store<AppState> = Logic.store) { self.store = store super.init(nibName: nil, bundle: nil) }ViewControllerのコードは3種類の役割に分割できます。
- #1 / State handling(State -> Game management or View update)
- #2 / Game management(Game management -> Dispatch Action)
- #3 / View update
#1 / State handling(State -> Game management or View update)
Reduxから変更があった場合に通知を受、新たなStateの状態に基づいて、Game managementを行ったたり、Viewの表示を更新する指示を出しています。
func newState(state: AppState)
はどんな変更であろうと通知され、表示を変更する処理を記載subscriberGameProgress
はGameProgress
に変更があった場合のみ通知され、GameProgress
の状況をパターンマッチのうえ、各状況における適切な処理を記載subscriberBoardContainer
はBoardContainer
に変更があった場合のみ通知され、盤面の変更があった場合に盤面の表示を変更する処理を記載class ViewController: UIViewController, StoreSubscriber { ... override func viewDidLoad() { super.viewDidLoad() boardView.delegate = self boardView.setUp(boardSetting: store.state.boardContainer.boardSetting) messageDiskSize = messageDiskSizeConstraint.constant store.subscribe(self) store.subscribe(subscriberGameProgress) { appState in appState.select { $0.gameProgress }.skipRepeats() } store.subscribe(subscriberBoardContainer) { appState in appState.select { $0.boardContainer }.skipRepeats() } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) loadGame() } func newState(state: AppState) { updatePlayerControls(state.gameProgress, playerSide: state.playerDark) updatePlayerControls(state.gameProgress, playerSide: state.playerLight) updateCountLabels(state.playerDark) updateCountLabels(state.playerLight) updateMessageViews(state.gameProgress) } private lazy var subscriberGameProgress = BlockSubscriber<GameProgress>() { [unowned self] in switch $0 { case .initialing: self.animationState.cancelAll() self.startGame() case .turn(let progress, let side, _, let computerThinking): self.updatePlayerActivityIndicators(side: side, computerThinking: computerThinking) switch progress { case .start: self.waitForPlayer() case .progressing: break } case .gameOver: break case .interrupt(let interrupt): switch interrupt { case .cannotPlaceDisk(let alert): switch alert { case .shouldShow: self.showCannotPlaceDiskAlert() case .none, .showing: break } case .resetConfirmation(let alert): switch alert { case .shouldShow: self.showRestConfirmationAlert() case .none, .showing: break } } } } private lazy var subscriberBoardContainer = BlockSubscriber<BoardContainer>() { [unowned self] in switch $0.changed { case .none: self.updateDisksForInitial($0.diskCoordinates) case .some(let changed): self.updateDisks(changed: changed, animated: true) { [weak self] _ in self?.nextTurn() } } } }Additional for ReSwift's subscriber
class BlockSubscriber<S>: StoreSubscriber { typealias StoreSubscriberStateType = S private let block: (S) -> Void init(_ block: @escaping (S) -> Void) { self.block = block } func newState(state: S) { self.block(state) } }#2 / Game management(Views -> State)
Game managementに関するメソッドが並びます。
View側からReduxのStateを変更するためのActionをdispatchする処理になります。extension ViewController { func saveGame() { store.dispatch(AppAction.saveGame()) } func loadGame() { store.dispatch(AppAction.loadGame()) } func newGame() { animationState.cancelAll() store.dispatch(AppAction.newGame()) } func startGame() { store.dispatch(AppAction.startGame) } func nextTurn() { store.dispatch(AppAction.nextTurn()) } func waitForPlayer() { store.dispatch(AppAction.waitForPlayer()) } func placeDisk(_ placedDiskCoordinate: PlacedDiskCoordinate) { store.dispatch(AppAction.placeDisk(placedDiskCoordinate)) } func changePlayer(side: Side, player: Player) { store.dispatch(AppAction.changePlayer(side: side, player: player)) animationState.cancel(at: side) } func cannotPlaceDisk(alert: Alert) { store.dispatch(AppAction.cannotPlaceDisk(alert)) } func resetConfirmation(alert: Alert) { store.dispatch(AppAction.resetConfirmation(alert)) } }#3 / View update(State -> Views)
Viewの表示を更新するためのメソッドが並びます。
extension ViewController { func updateMessageViews(_ gameProgress: GameProgress) { switch gameProgress { case .initialing, .interrupt: break case .turn(_, let side, _, _): messageDiskSizeConstraint.constant = messageDiskSize messageDiskView.disk = side.disk messageLabel.text = "'s turn" case .gameOver(let gameOver): switch gameOver { case .won(let winner): messageDiskSizeConstraint.constant = messageDiskSize messageDiskView.disk = winner.disk messageLabel.text = " won" case .tied: messageDiskSizeConstraint.constant = 0 messageLabel.text = "Tied" } } } func updateDisksForInitial(_ diskCoordinates: [OptionalDiskCoordinate]) { diskCoordinates.forEach { boardView.updateDisk($0.disk, coordinate: $0.coordinate, animated: false) } } func updateDisks(changed: BoardChanged, animated isAnimated: Bool, completion: ((Bool) -> Void)? = nil) { let disk = changed.placedDiskCoordinate.disk let placedCoordinate = changed.placedDiskCoordinate.coordinate let flippedCoordinates = changed.flippedDiskCoordinates.map { $0.coordinate } if isAnimated { animationState.createAnimationCanceller() updateDisksWithAnimation(at: [placedCoordinate] + flippedCoordinates, to: disk) { [weak self] finished in guard let self = self else { return } if self.animationState.isCancelled { return } self.animationState.cancel() completion?(finished) self.saveGame() } } else { DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.boardView.updateDisk(disk, coordinate: placedCoordinate, animated: false) flippedCoordinates.forEach { self.boardView.updateDisk(disk, coordinate: $0, animated: false) } completion?(true) self.saveGame() } } } private func updateDisksWithAnimation<C: Collection>(at coordinates: C, to disk: Disk, completion: @escaping (Bool) -> Void) where C.Element == Coordinate { guard let coordinate = coordinates.first else { completion(true) return } boardView.updateDisk(disk, coordinate: coordinate, animated: true) { [weak self] finished in guard let self = self else { return } if self.animationState.isCancelled { return } if finished { self.updateDisksWithAnimation(at: coordinates.dropFirst(), to: disk, completion: completion) } else { coordinates.forEach { self.boardView.updateDisk(disk, coordinate: $0, animated: false) } completion(false) } } } private func updatePlayerActivityIndicators(side: Side, computerThinking: ComputerThinking) { switch computerThinking { case .thinking: self.playerActivityIndicators[side.index].startAnimating() case .none: self.playerActivityIndicators.forEach { $0.stopAnimating() } } } func updatePlayerControls(_ gameProgress: GameProgress, playerSide: PlayerSide) { playerControls[playerSide.side.index].selectedSegmentIndex = playerSide.player.rawValue playerControls.forEach { switch gameProgress { case .turn: $0.isEnabled = true case .initialing, .interrupt, .gameOver: $0.isEnabled = false } } } func updateCountLabels(_ playerSide: PlayerSide) { countLabels[playerSide.side.index].text = "\(playerSide.count)" } func showCannotPlaceDiskAlert() { cannotPlaceDisk(alert: .showing) let alertController = UIAlertController( title: "Pass", message: "Cannot place a disk.", preferredStyle: .alert ) alertController.addAction(UIAlertAction(title: "Dismiss", style: .default) { [weak self] _ in self?.cannotPlaceDisk(alert: .none) self?.nextTurn() }) present(alertController, animated: true) } func showRestConfirmationAlert() { resetConfirmation(alert: .showing) let alertController = UIAlertController( title: "Confirmation", message: "Do you really want to reset the game?", preferredStyle: .alert ) alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in self?.resetConfirmation(alert: .none) self?.waitForPlayer() }) alertController.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in self?.newGame() }) present(alertController, animated: true) } }User inputs
extension ViewController { @IBAction func pressResetButton(_ sender: UIButton) { resetConfirmation(alert: .shouldShow) } @IBAction func changePlayerControlSegment(_ sender: UISegmentedControl) { guard let index = playerControls.firstIndex(of: sender) else { return } let side: Side switch index { case 0: side = .sideDark case 1: side = .sideLight default: preconditionFailure() } changePlayer(side: side, player: sender.convertToPlayer) } } extension ViewController: BoardViewDelegate { func boardView(_ boardView: BoardView, didSelectCellAt coordinate: Coordinate) { guard !animationState.isAnimating else { return } guard case .turn(_, let side, let player, _) = store.state.gameProgress else { return } guard case .manual = player else { return } placeDisk(PlacedDiskCoordinate(disk: side.disk, coordinate: coordinate)) } }ロギング
リファクタリングの手がかりとして状態の変化を都度ログに出力しました。
- Action, ActionCreatorがdispatchされたときはパラメータも合わせて出力
- 次のターンになると盤面のデータ状態を出力
- Logic.AppAction.changePlayer(side: sideLight, player: computer) START ▿ Logic.AppPrivateAction.changePlayer ▿ changePlayer: (2 elements) - side: Logic.Side.sideLight - player: Logic.Player.computer - Logic.AppAction.waitForPlayer() START - Logic.AppAction.playTurnOfComputer() START - Logic.AppPrivateAction.startComputerThinking - Logic.AppAction.playTurnOfComputer() END - Logic.AppAction.waitForPlayer() END - Logic.AppAction.saveGame() START - Logic.AppPrivateAction.finisedSaveGame - Logic.AppAction.saveGame() END - Logic.AppAction.changePlayer(side: sideLight, player: computer) END - Logic.AppAction.nextTurn() from: sideDark to: sideLight - Logic.AppPrivateAction.nextTurn - Logic.AppPrivateAction.validateTurn @01234567 0-------- 1-------- 2---x---- 3---xx--- 4---xo--- 5-------- 6-------- 7-------- - Logic.AppAction.waitForPlayer() START - Logic.AppAction.playTurnOfComputer() START - Logic.AppPrivateAction.startComputerThinking - Logic.AppAction.playTurnOfComputer() END - Logic.AppAction.waitForPlayer() END - Logic.AppAction.saveGame() START - Logic.AppPrivateAction.finisedSaveGame - Logic.AppAction.saveGame() END - Logic.AppPrivateAction.endComputerThinking ▿ Logic.AppAction.placeDisk ▿ placeDisk: Logic.PlacedDiskCoordinate - disk: Logic.Disk.diskLight ▿ coordinate: Logic.Coordinate - x: 2 - y: 2 - Logic.AppAction.nextTurn() from: sideLight to: sideDark - Logic.AppPrivateAction.nextTurn - Logic.AppPrivateAction.validateTurn @01234567 0-------- 1-------- 2--ox---- 3---ox--- 4---xo--- 5-------- 6-------- 7-------- - Logic.AppAction.waitForPlayer() START - Logic.AppAction.playTurnOfComputer() START - Logic.AppPrivateAction.startComputerThinking - Logic.AppAction.playTurnOfComputer() END - Logic.AppAction.waitForPlayer() END - Logic.AppAction.saveGame() START - Logic.AppPrivateAction.finisedSaveGame - Logic.AppAction.saveGame() END - Logic.AppPrivateAction.endComputerThinking ▿ Logic.AppAction.placeDisk ▿ placeDisk: Logic.PlacedDiskCoordinate - disk: Logic.Disk.diskDark ▿ coordinate: Logic.Coordinate - x: 5 - y: 4CI/TEST
GitHub ActionでXcodeのビルドとテストを実施できるようにしました。
# .github/workflows/build.yml on: push: branches: - 'master' pull_request: branches: - '**' env: project_nmae: Reversi scheme: Reversi configuration: Debug name: Xcode build jobs: validate: name: Validate runs-on: macOS-latest strategy: matrix: destination: - "platform=iOS Simulator,OS=13.4.1,name=iPhone 11 Pro" steps: - name: Checkout uses: actions/checkout@master - name: Switch to workspace directory run: cd $GITHUB_WORKSPACE - name: Install tooling run: sudo xcode-select -s /Applications/Xcode_11.4.1.app - name: Resolve swift package dependencies run: xcodebuild -resolvePackageDependencies -scheme '${{ env.scheme }}' -clonedSourcePackagesDirPath ./.swiftpackages -derivedDataPath ./.build - name: Run tests ${{ matrix.destination }} run: xcodebuild -sdk iphonesimulator -scheme '${{ env.scheme }}' -configuration '${{ env.configuration }}' -destination '${{ matrix.destination }}' -clonedSourcePackagesDirPath ./.swiftpackages -derivedDataPath ./.build clean test | xcpretty既知の不具合への対処
本チャレンジではリファクタリング対応にとどまらず、潜在する不具合を発見し的確に対処する必要があります。
手詰まりしたときの要対応事象 その1
手詰まりになった場合、アラートが表示された状態でアプリを終了すると、再度アプリを起動して前回終了時をロードしたときにクラッシュします。
手詰まりしたときの要対応事象 その2
以下のように、ひとまずクラッシュしないように改修すると、今度は再度アプリを起動して前回終了時をロードしたときに手詰まりのアラートが表示されず当該ターンではディスクを置けない状態に陥ります。
diff --git a/Reversi/ViewController.swift b/Reversi/ViewController.swift index 321824a..411c06b 100644 --- a/Reversi/ViewController.swift +++ b/Reversi/ViewController.swift @@ -285,6 +285,7 @@ extension ViewController { /// "Computer" が選択されている場合のプレイヤーの行動を決定します。 func playTurnOfComputer() { guard let turn = self.turn else { preconditionFailure() } + guard !validMoves(for: turn).isEmpty else { return } let (x, y) = validMoves(for: turn).randomElement()!リセットアラートの表示時の要対応事象
リセットボタンを押下し、リセットの確認アラートを表示している状態でもコンピュータの操作は可能なのでゲームは進行していきます。このとき、手詰まりが発生すると、手詰まりのアラートを表示すべきところですが、すでにリセットの確認アラートを表示しているためリセットの確認アラートを表示できず操作ができない状態に陥ります。
リグレッションしないように気を付けるところ
- 1. コンピュータが思考中にリセットした場合、リセット後のゲームでコンピュータの思考中が継続され誤ってディスクを指さないこと
- 2. フリップアニメーション中にリセットした場合、リセット後のゲームでフリップアニメーションが継続されないこと
1. 2. まとめ
反省点
- Reduxアーキテクチャに移行にあたって、リファクタリングがおおむね完了するまでテストコードを導入できなかった
- 移行が完了しないとReduxを活かしたテストコードを導入できなかったため
- テストコードでリファクタリングの過程にリグレッションしないことを担保したかった
- リファクタリング前と後でコードステップ数を計測すると67%増加していた
- Reduxのメリットの堅牢を求めるあまり、複雑なコードになったり過剰なリファクタリングとなった部分が否めない
コードステップ数
- リファクタリング前 リファクタリング後 ステップ数 775 1,295 未完成部分
今後の課題です。
- テストコードの記述がほとんどありません、もっとテストを書くべき!
- システム的な異常系のハンドリングは
preconditionFailure
とassertionFailure
の活用にとどまっており、ユーザーに異常状態を通知できていない感想
リバーシという題材のチョイスがよく程よいボリューム感とリバーシロジックの難易度があり、ベースコードのファトコードの再現具合も絶妙で多様なアーキテクチャでリファクタリングのアプローチが可能なうえ、噛めば噛むほど味の出るすばらしいチャレンジだと感じました。
- 投稿日:2020-05-16T20:59:59+09:00
Flutter Web View で Localの HTML , CSSを表示する
はじめに
皆さんはFlutter触ってますか?
Googleが開発しているモバイル向けクロスプラットフォーム,かつWebまでかけちゃうぜ!!ってことで注目しいましたが,思ったとおりにできることも増えてきて,「もうアプリはFlutterで良いじゃん」も近いのかなと思っている今日このごろ.
今回はLocal(端末)に保存したHtml, Css の Web View 表示で手間取ったのでまとめていきます.Web Viewって?
アプリ内でWebページを表示できる機能です.
アプリによってはWeb Viewだけで実装しているようなのもちらほら...
そんなわけで結構普通に必要な機能です.
今回は端末内に保存したHtmlを表示させることが目的です.
Flutterではこちらのパッケージを使うことで実装できます.環境
$ flutter doctor Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, v1.12.13+hotfix.5, on Mac OS X 10.15.4 19E287, locale ja-JP) [✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2) [✓] Xcode - develop for iOS and macOS (Xcode 11.4.1) [✓] Android Studio (version 3.6) [!] IntelliJ IDEA Ultimate Edition (version 2020.1.1) ✗ Flutter plugin not installed; this adds Flutter specific functionality. ✗ Dart plugin not installed; this adds Dart specific functionality. [✓] Connected device (1 available)導入&問題発生
いつもどおりパッケージを入れて導入しました.
HTMLも/assets/html/view.htmlに配置して,pubspecに追記.pubspec.yamldependencies: flutter: sdk: flutter webview_flutter: ^0.3.21 cupertino_icons: ^0.1.2 dev_dependencies: flutter_launcher_icons: ^0.7.5 flutter_test: sdk: flutter pedantic_mono: any flutter: uses-material-design: true assets: - assets/icon/ - assets/html/ flutter_icons: android: true ios: true image_path: 'assets/icon/icon.png' adaptive_icon_foreground: 'assets/icon/icon_foreground.png' adaptive_icon_background: "#ffa103"iOSでの設定のために /iOS/Runner/info.plist に追記
info.plist<key>io.flutter.embedded_views_preview</key>Mainをこちらを参考に書く.
main.dartimport 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:webview_flutter/webview_flutter.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { final String pageTitle = 'Web View Local'; @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: 'Web View', theme: ThemeData( primarySwatch: Colors.amber, ), home: WebViewPage(title: pageTitle), ); } } class WebViewPage extends StatefulWidget { const WebViewPage({Key key, this.title}) : super(key: key); final String title; @override _WebViewState createState() => _WebViewState(); } class _WebViewState extends State<WebViewPage> { WebViewController _controller; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: WebView( onWebViewCreated: (WebViewController webViewController) async { // 生成されたWebViewController情報を取得する _controller = webViewController; // HTMLファイルのURL(ローカルファイルの情報)をControllerに追加する処理 await _loadHtmlFromAssets(); }, // javascriptを有効化 javascriptMode: JavascriptMode.unrestricted, ), ); } /// HTMLファイルを読み込む処理(非同期) Future _loadHtmlFromAssets() async { //HTMLファイルを読み込んでHTML要素を文字列で返す final fileText = await rootBundle.loadString('assets/html/view.html'); await _controller.loadUrl( Uri.dataFromString( fileText, mimeType: 'text/html', encoding: Encoding.getByName('utf-8') ).toString()); } }これでDebugしてみたのですが... 動かない!! Androidだけ!!
問題解決
そこでふと思い出したのですが,前にFlutterに学習済みモデル(.tflite)を入れた時,Androidに拡張子を認識させる呪文を唱えた気がします.
そこで物は試しにと /android/app/src/build.gradle にbuild.gradle: : buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } //ここを追記 aaptOptions { noCompress "html" } }と書いてみると,表示された!!
どうやらapk作成の際に中に html が取り込まれてしまっていたみたいです.
無事解決!!おわりに
ということでなんとか問題は解決しました.
ちなみに別ファイルに分けていたcssを読み込んでくれない,などのトラブルは有りましたが,これもinline表記することで解決しました.
めでたしめでたし!!参考資料(ありがとうございます!!)
【Flutter】WebViewを使ってWebサイトとローカルHTMLファイルを表示させる方法
Google公式Flutter用WebViewプラグインを一通り使ってみた
Loading Local Assets in WebView in Flutter
- 投稿日:2020-05-16T19:37:23+09:00
リリース済みのiosアプリの dSYMファイルの場所(iTunesConnectから取得)
- 投稿日:2020-05-16T18:40:48+09:00
【Flutter】超オススメの「state_notifier + freezed」を使って、カウンターアプリをつくるよ
はじめに
いま、Flutter界隈では、「state_notifier + freezed」を使って開発するのがアツい! と、評判です。
Flutter state_notifierいい感じなので使ったほうが良いですよ
https://qiita.com/_masaokb/items/fe77495db0aeba226d2a
「state_notifier」は、providerと組み合わせて使い、Widgetから「状態」と「ロジック」を簡単に分離し&通知することができるライブラリです。Widgetの状態管理を楽にしてくれたり、無駄なリビルドを抑制したりしてくれます。
「freezed」は、State(状態)を「データを保持するだけのクラス」として変更不可(イミュータブル)なクラスとして表現することができます。
Flutter界隈のエンジニアの方々もオススメされているので、「流行ってるみたいだし、めっちゃよさげだし、私も乗り遅れないようにしないと」と思いました。
次にやるFlutter案件
— 文字数カウントメモ開発者? takashi (@cloverkizuna) May 15, 2020
・state_notifier
・freezed
を使うことにした?state_notifierとfreezedパッケージで何かアプリ作ってみようかな!
— Shogo?宮崎 | 対局時計さん⚫️⏰⚪️ (@shogo0525) May 17, 2020
StateNotifierを使ったFlutterのアプリ設計 https://t.co/bDrDGkVopvFlutter state notifier と freezed の おかけで 死ぬほど楽
— 奥村晋太郎 (@120921Shin) May 12, 2020
どっかのタイミングでこの知見は共有したいstate_notifierパッケージがとても良いので、それベースに変えた( ´・‿・`)基本は同じだけど( ´・‿・`)
— mono ? @自宅 ? (@_mono) March 10, 2020
- StateNotifierを基本的に常時利用
- StateNotifierが肥大化したら適宜、子StateNotifier的なものに分割して移譲
- stateクラスは基本的にfreezed利用
- 基本的な扱い方は変わらず https://t.co/e1C56Es4mp私も、実際に使ってみて、めちゃくちゃ良かった ですし、オススメ です。
そこで本記事では、「state_notifier + freezed」について、カウンターアプリを作りながら説明します。
本記事のコードについて、以下のGitHubリポジトリに公開しています。
https://github.com/karamage/flutter_state_notifier_freezed_samplestate_notifier について
https://pub.dev/packages/state_notifier
state_notifier は、providerでの状態管理 を、より簡単に、楽にするライブラリです。
Flutterで、状態管理のパターンとして、以下のものがよく使われています。
- setState(StatefulWidget)
- Redux
- BLoC(Stream + InheritedWidget/Scoped Model)
- provider(ChangeNotifier)
現時点で、Flutterの状態管理において ベストプラクティスは、providerを使うこと だと思います。
(※個人の感想です。Flutterは進化が早いし、流行り廃りが激しい分野なので、これからどうなるかはワカランです。)
Google公式においても、providerの使用を推奨しています。
(Pragmatic State Management in Flutter (Google I/O'19))state_notifierは、providerをより使いやすくコードをスッキリと楽に書けるようにしてくれるパッケージです。
従来のproviderパターンにおいて、ChangeNotifierを使ってゴリゴリとコードを書いていたのですが、コードが少々冗長になり、Widget階層が深くなりがちな問題があります。
state_notifierベースでproviderのコードを書くと、
ChangeNotifierを使用した場合よりコードがスッキリし、
コードを書いていて気持ちが良いです。私は、これまでChangeNotifierベースでコードを書いていたのですが、
これからはstate_notifierベースに書き換えていこうと思います。freezed について
https://pub.dev/packages/freezed
freezedパッケージは、イミュータブルなデータモデルを作成するのに使います。
Stateは、データを保持するだけのクラスにしたいので、 変更不可(イミュータブル/freezed)なクラスにします。
state_notifierでは、State(状態)をイミュータブルなデータモデルとして扱います。
state_notifierとfreezedは相性が良く、セットで使うのが推奨されます。
freezedを使うと、StateにcopyWith(clone)メソッドが自動的に生えるので、便利です。state_notifier と freezed をパッケージインストール
pubspec.yaml を以下のように書きます。
pubspec.yamldependencies: flutter: sdk: flutter provider: ^4.1.0-dev state_notifier: ^0.4.0 flutter_state_notifier: ^0.3.0 freezed_annotation: dev_dependencies: flutter_test: sdk: flutter json_serializable: build_runner: freezed:json_serializableは、今回のアプリではJSONを扱わないので入れなくても良いのですが、JSONの扱う際にfreezedとセットでいれとくと便利です。
ターミナルで以下のコマンドを叩くと、state_notifier と freezed パッケージのインストールが完了します。
flutter pub getカウンターアプリを実装する
ここで、flutter createした際のボイラーテンプレートのカウンターアプリを、「state_notifier + freezed」を使用して実装してみます。
CounterStateとCounterStateNotifierを定義する
新規ファイル「counter_state.dart」 を作成し、以下のコードを記述してください。
counter_state.dartimport 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:state_notifier/state_notifier.dart'; part 'counter_state.freezed.dart'; part 'counter_state.g.dart'; @freezed abstract class CounterState with _$CounterState { const factory CounterState({ @Default(0) int count, }) = _CounterState; factory CounterState.fromJson(Map<String, dynamic> json) => _$CounterStateFromJson(json); } class CounterStateNotifier extends StateNotifier<CounterState> { CounterStateNotifier() : super(const CounterState()) {} increment() => state = state.copyWith(count: state.count + 1); }その後、ターミナルで、以下のコマンドを叩いて、「counter_state.freezed.dart」「counter_state.g.dart」を自動生成してください。
flutter pub pub run build_runner buildcounter_state.dart では、
「CounterState」
クラスと「CounterStateNotifier」
クラスを定義しています。CounterState
CounterStateは、状態データの入れ物で、イミュータブルなデータクラスとして定義します。
カウンターの値の状態変数として
「int counter」
が定義されています。デフォルト値を設定するには
「@Default(0) int count」
のように記述します。
「@freezed」
アノテーションをつけることによって、copyWithメソッドが自動で生えます。copyWithメソッドは状態を更新する場合に、新しく状態を生成する(clone)ために使います。
「CounterState.fromJson」
メソッドは、今回のカウンターアプリでは使用しませんが、JSONからStateを生成する場合によく使うので記述しています。CounterStateNotifier
CounterStateNotifierは、状態を操作するロジックを管理し、Widgetに状態の変更を通知します。
MVVMモデルで解釈すると、「ViewModel」に相当します。
「inclement」
メソッドは、CouterStateのcountを1カウントアップした状態をcopyWithメソッドを使用して、新しく状態を作成し、更新しています。CounterStateNotifierを使用したWidgetを作成する
main.dartに以下のコードを記述します。
main.dartimport 'package:flutter/material.dart'; import 'package:flutter_state_notifier/flutter_state_notifier.dart'; import 'package:provider/provider.dart'; import 'package:state_notifier_example/counter_state.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: StateNotifierProvider<CounterStateNotifier, CounterState>( create: (_) => CounterStateNotifier(), child: HomePage(), ), ); } } class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('state_notifier sample'), ), body: Center( child: Text( context.select<CounterState, int>((state) => state.count).toString(), ), ), floatingActionButton: FloatingActionButton.extended( onPressed: () => context.read<CounterStateNotifier>().increment(), label: Text('1'), icon: Icon(Icons.add), ), ); } }Widgetツリーの上層で、StateNotifierProviderを作成すれば、その子Widgetであればどこでも、CounterStateNotifierを取得することができます。
ChangeNotifierを使った場合、Consumer等を挟まなくてはいけなかったのですが、state_notifierの場合スッキリ書けます。
main.darthome: StateNotifierProvider<CounterStateNotifier, CounterState>( create: (_) => CounterStateNotifier(), child: HomePage(), ),CounterStateNotifierを取得したい場合、以下のようにすれば、取得できます。
main.dartcontext.select<CounterState, int>((state) => state.count).toString(), ... onPressed: () => context.read<CounterStateNotifier>().increment(),context.readとcontext.selectには、以下の違いがあります。
context.read<CounterStateNotifier>()
- State更新時リビルドしない
- StateNotifierのメソッドを使いたいときに使う
context.select<CounterState, int>()
- State更新時リビルドする
- 状態が変わったときに画面に反映したい場合に使う
おわりに
「state_notifier」を使用することにより、簡単にWidgetから「状態」と「ロジック」を分離することができました。
「freezed」を使用することにより、Stateをデータを保持するだけのクラス、 変更不可(イミュータブル/freezed)なクラスにすることができました。コードがきれいでスッキリして、好きです。
超オススメですので、「state_notifier + freezed」を使ってみてはいかがでしょうか。
本記事のコードについて、以下のGitHubリポジトリに公開しています。
https://github.com/karamage/flutter_state_notifier_freezed_sample
- 投稿日:2020-05-16T15:29:56+09:00
TwitterのプロフィールであるようなUITableViewのヘッダーの画像を引っ張って拡大させる機能をたった数行のコードで実装する
Tiwtter等のアプリでよくあるプロフィール画面のカバー画像を下に引っ張ると拡大されるPrallaxHeader機能(っていうんですかね?)ですが、
実はこれめちゃくちゃ簡単に実装できます。GitHubにサンプルもあります。
環境
Xcode Version 11.4.1 (11E503a)
Swift version 5.2.2 (swiftlang-1103.0.32.6 clang-1103.0.32.51).実装
使用しているはUITableViewとtableHeaderViewです。
まあこの構成はよくあると思いますが、ポイントはヘッダー画像をtableHeaderViewに入れないことです。DebugViewHierarchyでみるとよくわかると思います。
Interface Builder
- UIViewController.View
- UIImageView ←これがTwitterでいう下に引っ張ると拡大されるカバー画像になる。
- UITableView ←透明にする。
- UIView ←これがtableHeaderView
- UIView ←これがカバーイメージの上にかぶる部分。透明にする。
- UIView ←ヘッダーに載せたいコンテンツを載せるView。背景色をつける。
- UILabel ←ヘッダーに載せたいコンテンツ。今回は適当にラベル。
上記の構成でViewを配置し、カバー画像のUIImageView下記のAutoLayoutをかけます。
- Align Top to SuperView Top
- Align Center X
- 任意のAspect Ratio
- Height Equal = 適当な数値カバーイメージの上にかぶる部分の透明なViewに下記のAutoLayoutをかけます。
- Align Top to SuperView Top
- Trailing Space To SuperView
- Leading Space To SuperView
- カバー画像と同じAspect Ratioあとは実装したいUIにあわせます。
コード
カバー画像Viewの
Align Top to SuperView Top
と- Height Equal = 適当な数値
をIBOutletで接続します。@IBOutlet private var coverImageHeightConstraint: NSLayoutConstraint! @IBOutlet private var coverImageTopConstraint: NSLayoutConstraint!
- Height Equal = 適当な数値
のままだと高さがずれるのでデフォルトの高さをセットしてあげてください。private var defaultCoverImageHeight: CGFloat { return self.view.frame.width / 3 } override func viewDidLoad() { super.viewDidLoad() self.coverImageHeightConstraint.constant = self.defaultCoverImageHeight }最後に、
UITableViewDelegate.scrollViewDidScroll(_ scrollView: UIScrollView)
の中にcontentsOffset.yの変化に応じて適当な値をIBOutletで接続した制約に与えてあげれば終わりです。extension ViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { self.coverImageHeightConstraint.constant = max(self.defaultCoverImageHeight, -(scrollView.contentOffset.y - self.defaultCoverImageHeight)) self.coverImageTopConstraint.constant = min(0, -scrollView.contentOffset.y) } }おわり
UITableViewの下にUIImageViewを配置し、必要な箇所の背景色を透明にして制約をいじるだけなので割と既存の実装に反映させやすいと思います。
また、スクロール位置に合わせてブラーを追加等も機能も簡単に追加できると思うのでおすすめです。
- 投稿日:2020-05-16T15:29:56+09:00
SwiftでTwitterのプロフィールであるようなUITableViewのヘッダーの画像を引っ張って拡大させる機能をたった数行のコードで実装する
Tiwtter等のアプリでよくあるプロフィール画面のカバー画像を下に引っ張ると拡大されるPrallaxHeader機能(っていうんですかね?)ですが、
実はこれめちゃくちゃ簡単に実装できます。GitHubにサンプルもあります。
環境
Xcode Version 11.4.1 (11E503a)
Swift version 5.2.2 (swiftlang-1103.0.32.6 clang-1103.0.32.51).実装
使用しているはUITableViewとtableHeaderViewです。
まあこの構成はよくあると思いますが、ポイントはヘッダー画像をtableHeaderViewに入れないことです。DebugViewHierarchyでみるとよくわかると思います。
Interface Builder
- UIViewController.View
- UIImageView ←これがTwitterでいう下に引っ張ると拡大されるカバー画像になる。
- UITableView ←透明にする。
- UIView ←これがtableHeaderView
- UIView ←これがカバーイメージの上にかぶる部分。透明にする。
- UIView ←ヘッダーに載せたいコンテンツを載せるView。背景色をつける。
- UILabel ←ヘッダーに載せたいコンテンツ。今回は適当にラベル。
上記の構成でViewを配置し、カバー画像のUIImageView下記のAutoLayoutをかけます。
- Align Top to SuperView Top
- Align Center X
- 任意のAspect Ratio
- Height Equal = 適当な数値カバーイメージの上にかぶる部分の透明なViewに下記のAutoLayoutをかけます。
- Align Top to SuperView Top
- Trailing Space To SuperView
- Leading Space To SuperView
- カバー画像と同じAspect Ratioあとは実装したいUIにあわせます。
コード
カバー画像Viewの
Align Top to SuperView Top
と- Height Equal = 適当な数値
をIBOutletで接続します。@IBOutlet private var coverImageHeightConstraint: NSLayoutConstraint! @IBOutlet private var coverImageTopConstraint: NSLayoutConstraint!
- Height Equal = 適当な数値
のままだと高さがずれるのでデフォルトの高さをセットしてあげてください。private var defaultCoverImageHeight: CGFloat { return self.view.frame.width / 3 } override func viewDidLoad() { super.viewDidLoad() self.coverImageHeightConstraint.constant = self.defaultCoverImageHeight }最後に、
UITableViewDelegate.scrollViewDidScroll(_ scrollView: UIScrollView)
の中にcontentsOffset.yの変化に応じて適当な値をIBOutletで接続した制約に与えてあげれば終わりです。extension ViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { self.coverImageHeightConstraint.constant = max(self.defaultCoverImageHeight, -(scrollView.contentOffset.y - self.defaultCoverImageHeight)) self.coverImageTopConstraint.constant = min(0, -scrollView.contentOffset.y) } }おわり
UITableViewの下にUIImageViewを配置し、必要な箇所の背景色を透明にして制約をいじるだけなので割と既存の実装に反映させやすいと思います。
また、スクロール位置に合わせてブラーを追加する機能等も簡単に追加できると思うのでおすすめです。
- 投稿日:2020-05-16T02:20:40+09:00
【超爆速 秒でコーディング】【Flutter】Adobe 公式 XD プラグインでXDからFlutterに変換してみた
XD→Flutterがやばい
この変換が30秒でできます。
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
AdobeがXDのプラグインによるFlutterの公式サポート開始
昨年のFlutter Interactイベントでは、Adobe から、Flutterコードをツールから直接エクスポートするAdobe XDのプラグインの初期プロトタイプのデモンストレーションが発表されました。
→ adobe公式ブログ
→ YouTubeに掲載されている昨年のFlutter Interact2020年05月14日より、AdobeのXD to Flutterプラグインがより広範なパブリックテストの早期アクセスとして利用できるようになりました。
XD→Flutter変換方法
変換するXDはこちら
プラグインをインストール後にXDからPluginを確認すると以下のような画面が表示されます。
FLUTTER PROJECT
Flutter projectの項目にパスを設定すると以下のエラーが発生します。
Could not find dependencies entry in pubspec.yaml for:adobe_xd.
以下を追記して、Flutterに
adobe_xd
パッケージを導入することでエラーは解決しました。Pubspec.yamldependencies: flutter: sdk: flutter cupertino_icons: ^0.1.2 adobe_xd:CODE PATH
通常は
lib
を選択すれば問題ありません。IMAGE PATH
画像を保存するパスを指定します。
私の場合は以下のディレクトリ構成のため、
assets/images
を指定しました。WIDGET NAME PREFIX
自動生成されるDartファイルのプリフィックスになります。
{WIDGET NAME PREFIX}{アートボード名}
が生成されます.Export All Widgetsを押下
実際に生成されたのがこちら
assets/images/skylar-em-mybmb.jpg
XD_list_screen.dart
XD_play_screen.dart
FlutterをRunしてみたらこんな感じ
生成されたDartファイルはこんな感じ
XD_play_screen.dartimport 'package:flutter/material.dart'; import 'dart:ui' as ui; import 'package:flutter_svg/flutter_svg.dart'; class XD_play_screen extends StatelessWidget { XD_play_screen({ Key key, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xffe8ecef), body: Stack( children: <Widget>[ Transform.translate( offset: Offset(-52.97, -70.64), child: // Adobe XD layer: 'back_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(75.27, 98.82), child: Container( width: 40.0, height: 41.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.65)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(75.27, 98.82), child: Container( width: 40.0, height: 41.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.65)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(88.97, 114.37), child: // Adobe XD layer: 'Backward arrow' (group) SvgPicture.string( _shapeSVG_383546ed0a284f858ac75e39984bace7, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(-113.97, 232.36), child: // Adobe XD layer: 'prev_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(201.02, 454.97), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(201.02, 454.97), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(216.11, 464.71), child: SvgPicture.string( _shapeSVG_cc8eebda6f964eb89a9d866fa47aad1d, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(-65.97, 262.36), child: // Adobe XD layer: 'next_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(312.89, 424.99), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(312.89, 424.99), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(321.02, 434.79), child: SvgPicture.string( _shapeSVG_ee800ac22d2b4193b83a78fb44bc30c0, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(63.03, -24.64), child: // Adobe XD layer: 'menu_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(245.37, 53.11), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(245.37, 53.11), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(261.14, 69.52), child: // Adobe XD layer: 'Menu' (group) SvgPicture.string( _shapeSVG_91dee9b001434d16b9d8288df49420ed, allowDrawingOutsideViewBox: true, ), ), Transform.translate( offset: Offset(261.14, 68.56), child: // Adobe XD layer: 'Menu' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 10.5, height: 10.5, decoration: BoxDecoration(), ), ), ], ), ), ], ), ), Transform.translate( offset: Offset(-90.97, 246.36), child: // Adobe XD layer: 'stop_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(247.99, 431.35), child: Container( width: 60.0, height: 60.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(30.0, 30.0)), gradient: LinearGradient( begin: Alignment(0.0, -1.0), end: Alignment(0.0, 1.0), colors: [ const Color(0xffff00c4), const Color(0xff000000) ], stops: [0.0, 1.0], ), boxShadow: [ BoxShadow( color: const Color(0x5e000000), offset: Offset(3, 3), blurRadius: 6) ], ), ), ), Transform.translate( offset: Offset(271.58, 450.51), child: SvgPicture.string( _shapeSVG_aec0aade47ee4ca5b903f27d7de86884, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(22.29, 566.95), child: SvgPicture.string( _shapeSVG_25f81fa21ab349f89461efc916412212, allowDrawingOutsideViewBox: true, ), ), Transform.translate( offset: Offset(-48.21, 204.45), child: Stack( children: <Widget>[], ), ), Transform.translate( offset: Offset(22.0, 601.0), child: Text( '00.00', style: TextStyle( fontFamily: 'Open Sans', fontSize: 18, color: const Color(0xff898c8d), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(302.0, 601.0), child: Text( '05.36', style: TextStyle( fontFamily: 'Open Sans', fontSize: 18, color: const Color(0xff898c8d), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(315.0, 646.0), child: // Adobe XD layer: 'Merge' (group) Stack( children: <Widget>[ Container( width: 20.0, height: 20.0, decoration: BoxDecoration(), ), Transform.translate( offset: Offset(0.0, 1.49), child: SvgPicture.string( _shapeSVG_00696ad8541d467faa4d8ea05320fccc, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(35.0, 646.0), child: // Adobe XD layer: 'Refresh' (group) SvgPicture.string( _shapeSVG_ecc8a9fa818245168f8aef45d5432b1b, allowDrawingOutsideViewBox: true, ), ), Transform.translate( offset: Offset(63.0, 169.79), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(-101.1, 0.0), child: // Adobe XD layer: 'skylar-em-mybmb' (shape) Container( width: 438.1, height: 246.4, decoration: BoxDecoration( image: DecorationImage( image: const AssetImage( 'assets/images/skylar-em-mybmb.jpg'), fit: BoxFit.fill, ), ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 248.0, height: 248.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(124.02, 124.02)), color: const Color(0xffffffff), border: Border.all( width: 1.0, color: const Color(0xff707070)), ), ), ), ], ), ), Transform.translate( offset: Offset(110.88, 422.89), child: Text( 'Leaving Heaven', style: TextStyle( fontFamily: 'Open Sans', fontSize: 18, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(155.95, 453.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0x94000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), ], ), ); } } const String _shapeSVG_383546ed0a284f858ac75e39984bace7 = '<svg viewBox="89.0 114.4 13.6 13.6" ><g transform="translate(88.97, 114.37)"><path transform="matrix(-1.0, 0.0, 0.0, -1.0, 13.61, 13.61)" d="M 6.803047180175781 0 L 5.5661301612854 1.236917734146118 L 10.24874687194824 5.919534683227539 L 0 5.919534683227539 L 1.024800016592356e-16 7.686559677124023 L 10.24874687194824 7.686559677124023 L 5.5661301612854 12.36917686462402 L 6.803047180175781 13.60609436035156 L 13.60609436035156 6.803047180175781 L 6.803047180175781 0 Z" fill="#000000" fill-opacity="0.74" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_cc8eebda6f964eb89a9d866fa47aad1d = '<svg viewBox="216.1 464.7 16.9 16.3" ><g transform="translate(216.11, 464.71)"><path transform="matrix(0.866025, 0.5, -0.5, 0.866025, 5.72, 0.0)" d="M 6.432093620300293 0 L 12.8641881942749 11.43483924865723 L 0 11.43483924865723 Z" fill="#920070" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(0.44, 2.65)" d="M 0 0 L 8.881784197001252e-16 13.64728927612305" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_ee800ac22d2b4193b83a78fb44bc30c0 = '<svg viewBox="321.0 434.8 16.9 16.7" ><g transform="translate(321.02, 434.79)"><path transform="matrix(0.866025, -0.5, 0.5, 0.866025, 0.0, 6.43)" d="M 6.432093620300293 0 L 12.8641881942749 11.43483924865723 L 0 11.43483924865723 Z" fill="#920070" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(16.42, 3.04)" d="M 0 0 L 8.881784197001252e-16 13.64728927612305" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_91dee9b001434d16b9d8288df49420ed = '<svg viewBox="261.1 69.5 10.5 10.5" ><g transform="translate(261.14, 68.56)"><path transform="translate(0.0, 0.96)" d="M 6.106226635438361e-16 10.52874183654785 L 5.233715995921835e-16 9.024304389953613 L 6.580931663513184 9.024304389953613 L 6.580931663513184 10.52874183654785 L 6.106226635438361e-16 10.52874183654785 Z M 3.489369166873035e-16 6.01659107208252 L 2.616857997960918e-16 4.512152194976807 L 10.52874183654785 4.512152194976807 L 10.52874183654785 6.01659107208252 L 3.489369166873035e-16 6.01659107208252 Z M 8.725107718654236e-17 1.504438281059265 L 0 0 L 10.52874183654785 6.106226635438361e-16 L 10.52874183654785 1.504438281059265 L 8.725107718654236e-17 1.504438281059265 Z" fill="#000000" fill-opacity="0.74" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_aec0aade47ee4ca5b903f27d7de86884 = '<svg viewBox="271.6 450.5 11.1 20.5" ><g transform="translate(271.58, 450.51)"><path transform="translate(0.0, 0.0)" d="M 0 0 L 1.188911008996542e-15 20.49995613098145" fill="none" stroke="#ffffff" stroke-width="3" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(11.15, 0.0)" d="M 0 0 L 1.165734175856414e-15 20.49995613098145" fill="none" stroke="#ffffff" stroke-width="3" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_25f81fa21ab349f89461efc916412212 = '<svg viewBox="22.3 566.9 326.1 29.4" ><g transform="translate(-48.21, 204.45)"><g transform="translate(70.5, 362.5)"><path transform="translate(0.0, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(6.94, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(13.88, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(20.81, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(27.75, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(34.69, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(41.63, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(48.57, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(55.51, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(62.44, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(69.38, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(76.32, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(83.26, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(90.2, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(97.14, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(104.07, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g><g transform="translate(181.51, 362.5)"><path transform="translate(0.0, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(6.94, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(13.88, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(20.81, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(27.75, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(34.69, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(41.63, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(48.57, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(55.51, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(62.44, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(69.38, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(76.32, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(83.26, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(90.2, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(97.14, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(104.07, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g><g transform="translate(292.53, 362.5)"><path transform="translate(0.0, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(6.94, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(13.88, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(20.81, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(27.75, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(34.69, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(41.63, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(48.57, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(55.51, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(62.44, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(69.38, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(76.32, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(83.26, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(90.2, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(97.14, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(104.07, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g></g></svg>'; const String _shapeSVG_00696ad8541d467faa4d8ea05320fccc = '<svg viewBox="0.0 1.5 19.9 16.4" ><path transform="translate(0.0, 1.49)" d="M 15.23306465148926 14.06151008605957 L 13.47568225860596 14.06151008605957 C 11.25159454345703 14.06151008605957 9.25841236114502 12.77399349212646 8.203941345214844 10.77989292144775 L 6.09520435333252 6.675831317901611 C 5.507744312286377 5.391578197479248 4.217473983764648 4.686625957489014 2.927101612091064 4.686625957489014 L 0 4.686625957489014 L 0 2.344944953918457 L 2.927101612091064 2.344944953918457 C 5.156288146972656 2.344944953918457 7.149572849273682 3.634093523025513 8.203941345214844 5.628194332122803 L 10.31267833709717 9.728992462158203 C 10.89503955841064 11.01324462890625 12.18541145324707 11.71819686889648 13.47568225860596 11.71819686889648 L 15.23306465148926 11.71819686889648 L 15.23306465148926 9.373251914978027 L 19.92254638671875 12.89311695098877 L 15.23306465148926 16.40645599365234 L 15.23306465148926 14.06151008605957 Z M 0 14.06151008605957 L 0 11.71819686889648 L 2.927101612091064 11.71819686889648 C 3.981470108032227 11.71819686889648 4.804831981658936 11.36735248565674 5.507744312286377 10.66240119934082 L 5.974754810333252 11.83405780792236 C 6.210656642913818 12.18979740142822 6.326108932495117 12.53574562072754 6.562214851379395 12.77399349212646 C 5.623298645019531 13.59480571746826 4.332926273345947 14.06151008605957 2.927101612091064 14.06151008605957 L 0 14.06151008605957 Z M 15.23306465148926 4.686625957489014 L 13.47568225860596 4.686625957489014 C 12.42141532897949 4.686625957489014 11.60305118560791 5.039102077484131 10.89503955841064 5.742422580718994 L 10.42823314666748 4.572397708892822 C 10.1921272277832 4.218289852142334 10.07667446136475 3.870709419250488 9.84077262878418 3.634093523025513 C 10.77958679199219 2.811649322509766 12.06995964050293 2.344944953918457 13.47568225860596 2.344944953918457 L 15.23306465148926 2.344944953918457 L 15.23306465148926 0 L 19.92254638671875 3.511705875396729 L 15.23306465148926 7.033202648162842 L 15.23306465148926 4.686625957489014 Z" fill="#898c8d" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></svg>'; const String _shapeSVG_ecc8a9fa818245168f8aef45d5432b1b = '<svg viewBox="35.0 646.0 20.0 20.0" ><g transform="translate(35.0, 646.0)"><path d="M 6.25 12.5 C 6.875 12.5 7.125000476837158 12.875 6.625000476837158 13.375 L 4.750000476837158 15.25 C 6.125 16.62500190734863 8.000000953674316 17.5 10 17.5 C 13.875 17.5 17 14.625 17.5 11 C 17.5 11 17.625 10 18.75 10 C 19.5 10 20 10.5 20 11.25 C 20 11.37500095367432 20 11.37500095367432 20 11.5 C 19.25 16.375 15.125 20 10 20 C 7.25 20 4.750000476837158 18.875 2.875000238418579 17.125 L 0.8750001788139343 19.12500190734863 C 0.3750000298023224 19.625 0 19.375 0 18.75 L 0 13.125 C 0 12.75 0.2500000298023224 12.5 0.625 12.5 L 6.25 12.5 Z M 13.75 7.5 C 13.125 7.5 12.875 7.125000476837158 13.375 6.625000476837158 L 15.25 4.750000476837158 C 13.875 3.375000238418579 12.00000095367432 2.5 10 2.5 C 6.125 2.5 3.000000238418579 5.375000476837158 2.5 9 C 2.5 9 2.375 10 1.25 10 C 0.5000000596046448 10 0 9.5 0 8.75 C 0 8.625000953674316 0 8.625000953674316 0 8.5 C 0.7500000596046448 3.625 4.875 0 10 0 C 12.75 0 15.25 1.125 17.125 2.875 L 19.12500190734863 0.875 C 19.625 0.3750000298023224 20 0.625 20 1.25 L 20 6.875 C 20 7.25 19.75000190734863 7.5 19.375 7.5 L 13.75 7.5 Z" fill="#898c8d" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>';XD_list_screen.dartimport 'package:flutter/material.dart'; import 'dart:ui' as ui; import 'package:flutter_svg/flutter_svg.dart'; import 'package:adobe_xd/specific_rect_clip.dart'; class XD_list_screen extends StatelessWidget { XD_list_screen({ Key key, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xffe8ecef), body: Stack( children: <Widget>[ Transform.translate( offset: Offset(-52.97, -70.64), child: // Adobe XD layer: 'back_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(75.27, 98.82), child: Container( width: 40.0, height: 41.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.65)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(75.27, 98.82), child: Container( width: 40.0, height: 41.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.65)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(88.97, 114.37), child: // Adobe XD layer: 'Backward arrow' (group) SvgPicture.string( _shapeSVG_96eb9fb5134542799a4150a6bd089216, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(63.03, -24.64), child: // Adobe XD layer: 'menu_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(245.37, 53.11), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(245.37, 53.11), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(261.14, 69.52), child: // Adobe XD layer: 'Menu' (group) SvgPicture.string( _shapeSVG_020664c98c7f40988eec662dd04f1eaa, allowDrawingOutsideViewBox: true, ), ), Transform.translate( offset: Offset(261.14, 68.56), child: // Adobe XD layer: 'Menu' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 10.5, height: 10.5, decoration: BoxDecoration(), ), ), ], ), ), ], ), ), Transform.translate( offset: Offset(116.0, 105.79), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(-58.41, 0.0), child: // Adobe XD layer: 'skylar-em-mybmb' (shape) Container( width: 253.1, height: 142.4, decoration: BoxDecoration( image: DecorationImage( image: const AssetImage( 'assets/images/skylar-em-mybmb.jpg'), fit: BoxFit.fill, ), ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 143.3, height: 143.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(71.65, 71.65)), color: const Color(0xffffffff), border: Border.all( width: 1.0, color: const Color(0xff707070)), ), ), ), ], ), ), Transform.translate( offset: Offset(118.88, 28.89), child: Text( 'Leaving Heaven', style: TextStyle( fontFamily: 'Open Sans', fontSize: 18, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(163.95, 59.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0x94000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(21.0, 299.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_76d2aedc11af4dc482a870903997dcff, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 369.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 439.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_bce56cc9a5f84e1aa05ddfa22e0b2068, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 509.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_ed54718f34e74e9dac15b29107fa6038, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 579.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_676e0afef6c443d280271cbb62d129df, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 649.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_4896be0f759c4917bc911be2e9723cf9, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 719.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_8ed41034be1b456fa5327cb28a2be403, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(-49.93, -393.81), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(56.93, 751.81), child: Container( width: 359.0, height: 74.0, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16.0), border: Border.all( width: 1.0, color: const Color(0xffe8ecef)), boxShadow: [ BoxShadow( color: const Color(0x45000000), offset: Offset(1, 1), blurRadius: 1) ], ), ), ), Transform.translate( offset: Offset(54.43, 749.31), child: SpecificRectClip( rect: Rect.fromLTWH(0, 0, 362, 77), child: UnconstrainedBox( alignment: Alignment.topLeft, child: Container( width: 362, height: 77, child: GridView.count( primary: false, padding: EdgeInsets.all(0), mainAxisSpacing: 20, crossAxisSpacing: 20, crossAxisCount: 1, childAspectRatio: 4.701298701298701, children: [ {}, ].map((map) { return Transform.translate( offset: Offset(-54.43, -749.31), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(56.93, 751.81), child: Container( width: 359.0, height: 74.0, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16.0), border: Border.all( width: 1.0, color: const Color(0xffe8ecef)), boxShadow: [ BoxShadow( color: const Color(0x45ffffff), offset: Offset(-1, -1), blurRadius: 1) ], ), ), ), ], ), ); }).toList(), ), ), ), ), ), Transform.translate( offset: Offset(120.96, 340.17), child: // Adobe XD layer: 'stop_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(247.99, 431.35), child: Container( width: 35.0, height: 35.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(17.5, 17.5)), gradient: LinearGradient( begin: Alignment(0.0, -1.0), end: Alignment(0.0, 1.0), colors: [ const Color(0xffff00c4), const Color(0xff000000) ], stops: [0.0, 1.0], ), boxShadow: [ BoxShadow( color: const Color(0x5e000000), offset: Offset(3, 3), blurRadius: 6) ], ), ), ), Transform.translate( offset: Offset(261.63, 442.3), child: SvgPicture.string( _shapeSVG_7183cc78301f48a287bb50591c2bf5cd, allowDrawingOutsideViewBox: true, ), ), ], ), ), ], ), ), ], ), ); } } const String _shapeSVG_96eb9fb5134542799a4150a6bd089216 = '<svg viewBox="89.0 114.4 13.6 13.6" ><g transform="translate(88.97, 114.37)"><path transform="matrix(-1.0, 0.0, 0.0, -1.0, 13.61, 13.61)" d="M 6.803047180175781 0 L 5.5661301612854 1.236917734146118 L 10.24874687194824 5.919534683227539 L 0 5.919534683227539 L 1.024800016592356e-16 7.686559677124023 L 10.24874687194824 7.686559677124023 L 5.5661301612854 12.36917686462402 L 6.803047180175781 13.60609436035156 L 13.60609436035156 6.803047180175781 L 6.803047180175781 0 Z" fill="#000000" fill-opacity="0.74" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_020664c98c7f40988eec662dd04f1eaa = '<svg viewBox="261.1 69.5 10.5 10.5" ><g transform="translate(261.14, 68.56)"><path transform="translate(0.0, 0.96)" d="M 6.106226635438361e-16 10.52874183654785 L 5.233715995921835e-16 9.024304389953613 L 6.580931663513184 9.024304389953613 L 6.580931663513184 10.52874183654785 L 6.106226635438361e-16 10.52874183654785 Z M 3.489369166873035e-16 6.01659107208252 L 2.616857997960918e-16 4.512152194976807 L 10.52874183654785 4.512152194976807 L 10.52874183654785 6.01659107208252 L 3.489369166873035e-16 6.01659107208252 Z M 8.725107718654236e-17 1.504438281059265 L 0 0 L 10.52874183654785 6.106226635438361e-16 L 10.52874183654785 1.504438281059265 L 8.725107718654236e-17 1.504438281059265 Z" fill="#000000" fill-opacity="0.74" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_76d2aedc11af4dc482a870903997dcff = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_bce56cc9a5f84e1aa05ddfa22e0b2068 = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_ed54718f34e74e9dac15b29107fa6038 = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_676e0afef6c443d280271cbb62d129df = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_4896be0f759c4917bc911be2e9723cf9 = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_8ed41034be1b456fa5327cb28a2be403 = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_7183cc78301f48a287bb50591c2bf5cd = '<svg viewBox="261.6 442.3 6.8 12.4" ><g transform="translate(261.63, 442.3)"><path transform="translate(0.0, 0.0)" d="M 0 0 L 1.188911008996542e-15 12.42906475067139" fill="none" stroke="#ffffff" stroke-width="3" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(6.76, 0.0)" d="M 0 0 L 1.165734175856414e-15 12.42906475067139" fill="none" stroke="#ffffff" stroke-width="3" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>';参考記事