craft-native 0.0.124 → 0.0.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,11 @@ import UIKit
6
6
  import UserNotifications
7
7
 
8
8
  let craftNativeCapabilityProtocolVersion = 1
9
+ /// URLSession's own request timeout, matching the bridge's 30-second deadline.
10
+ let craftNativeFetchTimeoutSeconds: TimeInterval = 30
11
+ /// A response body larger than this rejects with RESPONSE_TOO_LARGE rather
12
+ /// than being copied into JavaScriptCore as one string.
13
+ let craftNativeFetchMaxResponseBytes = 8 * 1024 * 1024
9
14
 
10
15
  struct CraftNativeActionError: Error {
11
16
  let code: String
@@ -21,12 +26,19 @@ enum CraftNativeActions {
21
26
  private static let cancellationLock = NSLock()
22
27
  private static var cancelledRequests = Set<String>()
23
28
  private static var biometricContexts = [String: LAContext]()
29
+ private static var fetchTasks = [String: URLSessionDataTask]()
30
+ private static let fetchSession: URLSession = {
31
+ let configuration = URLSessionConfiguration.default
32
+ configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
33
+ configuration.timeoutIntervalForResource = craftNativeFetchTimeoutSeconds
34
+ return URLSession(configuration: configuration)
35
+ }()
24
36
  private static var initialDeepLinkClaimed = false
25
37
  private static var database: OpaquePointer?
26
38
  private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
27
39
 
28
40
  static func capabilities(config: CraftConfig) -> [String] {
29
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle"]
41
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch"]
30
42
  if config.enableBiometric { values.append("biometric") }
31
43
  if config.enableSecureStorage { values.append("secureStorage") }
32
44
  if config.enableLocalDatabase { values.append("database") }
@@ -115,6 +127,9 @@ enum CraftNativeActions {
115
127
  }
116
128
  authenticate(requestToken: requestToken, args: args, completion: completion)
117
129
  return
130
+ case ("Network", "fetch"):
131
+ fetch(requestToken: requestToken, args: args, completion: completion)
132
+ return
118
133
  case ("Lifecycle", "getState"):
119
134
  sync = .success(currentAppState())
120
135
  case ("DeepLinks", "getInitialURL"):
@@ -147,8 +162,91 @@ enum CraftNativeActions {
147
162
  cancellationLock.lock()
148
163
  cancelledRequests.insert(requestToken)
149
164
  let context = biometricContexts.removeValue(forKey: requestToken)
165
+ let task = fetchTasks.removeValue(forKey: requestToken)
150
166
  cancellationLock.unlock()
151
167
  context?.invalidate()
168
+ task?.cancel()
169
+ }
170
+
171
+ /// `fetch` for native screens: one URLSession data task per request, keyed
172
+ /// by the request token so the bridge deadline, `API_CANCEL` and route
173
+ /// teardown all cancel the transfer. Bodies are UTF-8 text both ways.
174
+ private static func fetch(
175
+ requestToken: String,
176
+ args: [Any],
177
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
178
+ ) {
179
+ guard let options = args.first as? [String: Any],
180
+ let address = options["url"] as? String,
181
+ let url = URL(string: address),
182
+ let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
183
+ url.host?.isEmpty == false else {
184
+ complete(requestToken, failure("INVALID_ARGUMENT", "fetch needs an absolute http or https URL"), completion)
185
+ return
186
+ }
187
+ let method = ((options["method"] as? String) ?? "GET").uppercased()
188
+ guard !method.isEmpty, method.allSatisfy({ $0.isLetter && $0.isASCII }) else {
189
+ complete(requestToken, failure("INVALID_ARGUMENT", "fetch method must be an HTTP token"), completion)
190
+ return
191
+ }
192
+ var request = URLRequest(url: url, timeoutInterval: craftNativeFetchTimeoutSeconds)
193
+ request.httpMethod = method
194
+ if let headers = options["headers"] as? [String: Any] {
195
+ for (name, value) in headers {
196
+ guard let value = value as? String else {
197
+ complete(requestToken, failure("INVALID_ARGUMENT", "fetch header values must be strings"), completion)
198
+ return
199
+ }
200
+ request.setValue(value, forHTTPHeaderField: name)
201
+ }
202
+ }
203
+ if let body = options["body"] as? String {
204
+ guard method != "GET", method != "HEAD" else {
205
+ complete(requestToken, failure("INVALID_ARGUMENT", "A GET or HEAD request cannot have a body"), completion)
206
+ return
207
+ }
208
+ request.httpBody = Data(body.utf8)
209
+ } else if let body = options["body"], !(body is NSNull) {
210
+ complete(requestToken, failure("INVALID_ARGUMENT", "fetch body must be a string"), completion)
211
+ return
212
+ }
213
+
214
+ let task = fetchSession.dataTask(with: request) { data, response, error in
215
+ cancellationLock.lock()
216
+ fetchTasks.removeValue(forKey: requestToken)
217
+ cancellationLock.unlock()
218
+ if let error = error {
219
+ let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
220
+ complete(requestToken, failure(code, error.localizedDescription), completion)
221
+ return
222
+ }
223
+ guard let http = response as? HTTPURLResponse else {
224
+ complete(requestToken, failure("NETWORK_ERROR", "fetch received a response that is not HTTP"), completion)
225
+ return
226
+ }
227
+ let body = data ?? Data()
228
+ guard body.count <= craftNativeFetchMaxResponseBytes else {
229
+ complete(requestToken, failure("RESPONSE_TOO_LARGE", "fetch responses are limited to \(craftNativeFetchMaxResponseBytes) bytes"), completion)
230
+ return
231
+ }
232
+ var headers: [String: String] = [:]
233
+ for (name, value) in http.allHeaderFields {
234
+ headers[String(describing: name).lowercased()] = String(describing: value)
235
+ }
236
+ complete(requestToken, .success([
237
+ "url": http.url?.absoluteString ?? address,
238
+ "status": http.statusCode,
239
+ "statusText": HTTPURLResponse.localizedString(forStatusCode: http.statusCode),
240
+ "headers": headers,
241
+ "redirected": http.url.map { $0 != url } ?? false,
242
+ "body": String(decoding: body, as: UTF8.self),
243
+ ]), completion)
244
+ }
245
+ cancellationLock.lock()
246
+ let alreadyCancelled = cancelledRequests.contains(requestToken)
247
+ if !alreadyCancelled { fetchTasks[requestToken] = task }
248
+ cancellationLock.unlock()
249
+ if !alreadyCancelled { task.resume() }
152
250
  }
153
251
 
154
252
  private static func claimInitialDeepLink() -> URL? {
@@ -535,6 +633,120 @@ enum CraftNativeActions {
535
633
  }
536
634
  }
537
635
 
636
+ // MARK: - Synchronous reads for a screen's first frame
637
+
638
+ extension CraftNativeActions {
639
+ /// The JSON text `craft.storage` keeps under `key`, or nil. The same store
640
+ /// as the asynchronous Storage module, read without a bridge round trip.
641
+ static func storageJSON(forKey key: String) -> String? {
642
+ guard let key = validKey(key), let data = UserDefaults.standard.data(forKey: storagePrefix + key) else { return nil }
643
+ return String(decoding: data, as: UTF8.self)
644
+ }
645
+
646
+ /// Stores JSON text under `key` (nil removes it). False for an invalid key
647
+ /// or text that is not JSON, which the asynchronous `get` could not read.
648
+ @discardableResult
649
+ static func setStorageJSON(_ json: String?, forKey key: String) -> Bool {
650
+ guard let key = validKey(key) else { return false }
651
+ guard let json else {
652
+ UserDefaults.standard.removeObject(forKey: storagePrefix + key)
653
+ return true
654
+ }
655
+ let data = Data(json.utf8)
656
+ guard (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) != nil else { return false }
657
+ UserDefaults.standard.set(data, forKey: storagePrefix + key)
658
+ return true
659
+ }
660
+
661
+ /// The Keychain item the web page's `craft.secureStorage` wrote under
662
+ /// `key`: a generic password whose account is the key, as CraftApp's
663
+ /// `secureStore` writes it. Nil when there is none.
664
+ static func webSecureValue(forKey key: String) -> String? {
665
+ guard let key = validKey(key) else { return nil }
666
+ let query: [String: Any] = [
667
+ kSecClass as String: kSecClassGenericPassword,
668
+ kSecAttrAccount as String: key,
669
+ kSecReturnData as String: true,
670
+ kSecMatchLimit as String: kSecMatchLimitOne,
671
+ ]
672
+ var result: AnyObject?
673
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
674
+ return String(data: data, encoding: .utf8)
675
+ }
676
+ }
677
+
678
+ /// Last-known data for a screen's first frame: one JSON file per name in
679
+ /// `Application Support/craft-snapshots/<name>.json`. A web page writes them
680
+ /// (`snapshots.set` in the mobile API) and a native screen reads them
681
+ /// synchronously before its first render, or writes its own.
682
+ enum CraftSnapshots {
683
+ private static let queue = DispatchQueue(label: "dev.craft.snapshots", qos: .utility)
684
+ private static let lock = NSLock()
685
+ /// Writes not on disk yet, so a read right after a write sees it.
686
+ private static var pending: [String: String?] = [:]
687
+
688
+ static var directory: URL? {
689
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
690
+ .appendingPathComponent("craft-snapshots", isDirectory: true)
691
+ }
692
+
693
+ /// Letters, digits, `-`, `_` and `.`, not starting with a dot: a file
694
+ /// name, never a path.
695
+ static func isValidName(_ name: String) -> Bool {
696
+ !name.isEmpty && name.count <= 128 && !name.hasPrefix(".")
697
+ && name.unicodeScalars.allSatisfy { $0.isASCII && (CharacterSet.alphanumerics.contains($0) || "-_.".unicodeScalars.contains($0)) }
698
+ }
699
+
700
+ static func url(for name: String) -> URL? {
701
+ guard isValidName(name) else { return nil }
702
+ return directory?.appendingPathComponent("\(name).json", isDirectory: false)
703
+ }
704
+
705
+ /// The snapshot's JSON text, or nil.
706
+ static func read(_ name: String) -> String? {
707
+ guard let url = url(for: name) else { return nil }
708
+ lock.lock()
709
+ let waiting = pending[name]
710
+ lock.unlock()
711
+ if let waiting { return waiting }
712
+ guard let data = try? Data(contentsOf: url) else { return nil }
713
+ return String(decoding: data, as: UTF8.self)
714
+ }
715
+
716
+ /// Replaces the snapshot with JSON text (nil deletes it) off the main
717
+ /// thread, atomically. `completion` runs on the main queue.
718
+ static func write(_ name: String, json: String?, completion: ((Bool) -> Void)? = nil) {
719
+ guard let url = url(for: name), let directory else {
720
+ DispatchQueue.main.async { completion?(false) }
721
+ return
722
+ }
723
+ if let json, (try? JSONSerialization.jsonObject(with: Data(json.utf8), options: [.fragmentsAllowed])) == nil {
724
+ DispatchQueue.main.async { completion?(false) }
725
+ return
726
+ }
727
+ lock.lock()
728
+ pending[name] = .some(json)
729
+ lock.unlock()
730
+ queue.async {
731
+ var ok = true
732
+ do {
733
+ if let json {
734
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
735
+ try Data(json.utf8).write(to: url, options: [.atomic])
736
+ } else if FileManager.default.fileExists(atPath: url.path) {
737
+ try FileManager.default.removeItem(at: url)
738
+ }
739
+ } catch {
740
+ ok = false
741
+ }
742
+ lock.lock()
743
+ if let current = pending[name], current == json { pending.removeValue(forKey: name) }
744
+ lock.unlock()
745
+ DispatchQueue.main.async { completion?(ok) }
746
+ }
747
+ }
748
+ }
749
+
538
750
  /// The Taptic Engine's generators, held for the life of the process.
539
751
  ///
540
752
  /// Each haptic used to make a fresh generator at the moment of the tap. A new
@@ -9,10 +9,10 @@ private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
9
9
  }
10
10
  }
11
11
 
12
- /// A keyed, recycling native list used by the stx-native FlatList primitive.
12
+ /// A keyed, recycling native list used by stx's native FlatList primitive.
13
13
  /// The screen controller owns rendered row state; this view owns collection
14
14
  /// diffs, viewport state, and UICollectionView cell reuse.
15
- final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout {
15
+ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout, UIGestureRecognizerDelegate {
16
16
  typealias RenderItem = (_ node: [String: Any], _ identity: String, _ previous: UIView?) -> UIView
17
17
 
18
18
  private final class Cell: UICollectionViewCell {
@@ -21,6 +21,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
21
21
  private(set) var representedIdentity: String?
22
22
  private(set) var hostedView: UIView?
23
23
  var onRecycle: ((String) -> Void)?
24
+ /// The width (or, sideways, height) the list gives this row, so a row
25
+ /// is measured at the list's width rather than the layout's estimate.
26
+ var fittingExtent: CGFloat?
24
27
 
25
28
  override func prepareForReuse() {
26
29
  super.prepareForReuse()
@@ -63,24 +66,25 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
63
66
  if let collectionView = ancestor as? UICollectionView,
64
67
  let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout,
65
68
  flowLayout.scrollDirection == .horizontal {
66
- let target = CGSize(
67
- width: UIView.layoutFittingCompressedSize.width,
68
- height: layoutAttributes.size.height
69
+ let height = fittingExtent ?? layoutAttributes.size.height
70
+ let measured = CraftNativeFlexLayout.measure(
71
+ hostedView,
72
+ width: nil,
73
+ height: height,
74
+ maxWidth: CraftNativeFlexLayout.unbounded,
75
+ maxHeight: height
69
76
  )
70
- let measured = hostedView.systemLayoutSizeFitting(
71
- target,
72
- withHorizontalFittingPriority: .fittingSizeLevel,
73
- verticalFittingPriority: .required
74
- )
75
- attributes.size.width = max(1, measured.width)
77
+ attributes.size = CGSize(width: max(1, ceil(measured.width)), height: max(1, height))
76
78
  } else {
77
- let target = CGSize(width: layoutAttributes.size.width, height: UIView.layoutFittingCompressedSize.height)
78
- let measured = hostedView.systemLayoutSizeFitting(
79
- target,
80
- withHorizontalFittingPriority: .required,
81
- verticalFittingPriority: .fittingSizeLevel
79
+ let width = fittingExtent ?? layoutAttributes.size.width
80
+ let measured = CraftNativeFlexLayout.measure(
81
+ hostedView,
82
+ width: width,
83
+ height: nil,
84
+ maxWidth: width,
85
+ maxHeight: CraftNativeFlexLayout.unbounded
82
86
  )
83
- attributes.size.height = max(1, measured.height)
87
+ attributes.size = CGSize(width: max(1, width), height: max(1, ceil(measured.height)))
84
88
  }
85
89
  return attributes
86
90
  }
@@ -119,6 +123,10 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
119
123
  private var isApplyingSnapshot = false
120
124
  private var pendingApply: (() -> Void)?
121
125
  private var pendingThemeRefresh = false
126
+ private let nativeRefreshControl = UIRefreshControl()
127
+ private var refreshHandler: (() -> Void)?
128
+ private let keyboardTapGesture = UITapGestureRecognizer()
129
+ private var keyboardShouldPersistTaps = "never"
122
130
 
123
131
  init() {
124
132
  let layout = CraftNativeFlatListLayout()
@@ -130,7 +138,12 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
130
138
  backgroundColor = .clear
131
139
  alwaysBounceVertical = true
132
140
  delegate = self
141
+ keyboardTapGesture.cancelsTouchesInView = false
142
+ keyboardTapGesture.delegate = self
143
+ keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
144
+ addGestureRecognizer(keyboardTapGesture)
133
145
  register(Cell.self, forCellWithReuseIdentifier: Cell.reuseIdentifier)
146
+ nativeRefreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
134
147
  diffableDataSource = UICollectionViewDiffableDataSource<Int, String>(collectionView: self) {
135
148
  [weak self] collectionView, indexPath, identity in
136
149
  guard let self = self,
@@ -142,6 +155,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
142
155
  let renderItem = self.renderItem else { return UICollectionViewCell() }
143
156
  let view = renderItem(item.node, identity, cell.hostedView)
144
157
  cell.onRecycle = self.recycleItem
158
+ cell.fittingExtent = self.itemExtent(isChrome: item.isChrome)
145
159
  cell.host(view, identity: identity)
146
160
  return cell
147
161
  }
@@ -149,6 +163,123 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
149
163
 
150
164
  required init?(coder: NSCoder) { nil }
151
165
 
166
+ /// Called when a list that does not scroll changes height, so the
167
+ /// containers around it make room.
168
+ var onFittingHeightChanged: (() -> Void)?
169
+
170
+ override var contentSize: CGSize {
171
+ didSet {
172
+ guard !isScrollEnabled, abs(oldValue.height - contentSize.height) > 0.5 else { return }
173
+ invalidateIntrinsicContentSize()
174
+ onFittingHeightChanged?()
175
+ }
176
+ }
177
+
178
+ override var isScrollEnabled: Bool {
179
+ didSet {
180
+ guard isScrollEnabled != oldValue else { return }
181
+ alwaysBounceVertical = isScrollEnabled && flowLayout.scrollDirection == .vertical
182
+ onFittingHeightChanged?()
183
+ }
184
+ }
185
+
186
+ /// The height of every row at `width`: what a list that does not scroll
187
+ /// (`scrollEnabled={false}`) takes inside a scroll view or a column.
188
+ /// Rows not measured yet count at their estimate, and the list asks for
189
+ /// room again as they are.
190
+ func fittingHeight(width: CGFloat) -> CGFloat {
191
+ guard !isScrollEnabled else { return 0 }
192
+ if abs(bounds.width - width) > 0.5 { frame.size.width = width }
193
+ layoutIfNeeded()
194
+ let content = flowLayout.collectionViewContentSize.height
195
+ return max(0, ceil(content + contentInset.top + contentInset.bottom))
196
+ }
197
+
198
+ /// The width a row gets: the list's own width inside its content padding,
199
+ /// shared between columns for data rows.
200
+ private func itemExtent(isChrome: Bool) -> CGFloat {
201
+ if flowLayout.scrollDirection == .horizontal {
202
+ return max(1, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
203
+ }
204
+ let width = max(1, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
205
+ let count = isChrome ? 1 : columns
206
+ let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
207
+ return max(1, (width - spacing) / CGFloat(count))
208
+ }
209
+
210
+ override func layoutSubviews() {
211
+ let estimate = flowLayout.scrollDirection == .horizontal
212
+ ? CGSize(width: 44, height: itemExtent(isChrome: true))
213
+ : CGSize(width: itemExtent(isChrome: false), height: 44)
214
+ if flowLayout.estimatedItemSize != estimate, bounds.width > 0 {
215
+ flowLayout.estimatedItemSize = estimate
216
+ }
217
+ super.layoutSubviews()
218
+ craftApplyCornerRadius()
219
+ }
220
+
221
+ func setContentContainerStyle(_ raw: [String: Any]?) {
222
+ func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
223
+ let padding = number(raw?["padding"]) ?? 0
224
+ let horizontal = number(raw?["paddingHorizontal"]) ?? padding
225
+ let vertical = number(raw?["paddingVertical"]) ?? padding
226
+ contentInset = UIEdgeInsets(
227
+ top: number(raw?["paddingTop"]) ?? vertical,
228
+ left: number(raw?["paddingLeft"]) ?? horizontal,
229
+ bottom: number(raw?["paddingBottom"]) ?? vertical,
230
+ right: number(raw?["paddingRight"]) ?? horizontal
231
+ )
232
+ scrollIndicatorInsets = contentInset
233
+ collectionViewLayout.invalidateLayout()
234
+ }
235
+
236
+ func setKeyboardShouldPersistTaps(_ value: Any?) {
237
+ let requested = value as? String
238
+ keyboardShouldPersistTaps = ["always", "handled", "never"].contains(requested) ? requested! : "never"
239
+ keyboardTapGesture.isEnabled = keyboardShouldPersistTaps != "always"
240
+ }
241
+
242
+ private var refreshingProp = false
243
+
244
+ /// `onRefresh` and `refreshing`, controlled as in React Native: a pull
245
+ /// the screen does not answer with `refreshing` true ends at once.
246
+ func setRefreshHandler(_ handler: (() -> Void)?, refreshing: Bool) {
247
+ refreshHandler = handler
248
+ refreshingProp = refreshing
249
+ let control = handler == nil ? nil : nativeRefreshControl
250
+ if refreshControl !== control { refreshControl = control }
251
+ if handler == nil || !refreshing {
252
+ if nativeRefreshControl.isRefreshing { nativeRefreshControl.endRefreshing() }
253
+ } else if !nativeRefreshControl.isRefreshing {
254
+ nativeRefreshControl.beginRefreshing()
255
+ }
256
+ }
257
+
258
+ @objc private func refreshTriggered() {
259
+ refreshHandler?()
260
+ DispatchQueue.main.async { [weak self] in
261
+ guard let self, !self.refreshingProp, self.nativeRefreshControl.isRefreshing else { return }
262
+ self.nativeRefreshControl.endRefreshing()
263
+ }
264
+ }
265
+
266
+ @objc private func keyboardTap() {
267
+ guard keyboardShouldPersistTaps != "always" else { return }
268
+ if keyboardShouldPersistTaps == "handled" {
269
+ let point = keyboardTapGesture.location(in: self)
270
+ var hit = hitTest(point, with: nil)
271
+ while let view = hit, view !== self {
272
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
273
+ hit = view.superview
274
+ }
275
+ }
276
+ window?.endEditing(true)
277
+ }
278
+
279
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
280
+ true
281
+ }
282
+
152
283
  var visibleItemIdentities: [String] {
153
284
  indexPathsForVisibleItems
154
285
  .sorted { $0.item < $1.item }
@@ -320,11 +451,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
320
451
  guard items.indices.contains(indexPath.item) else { return CGSize(width: bounds.width, height: 44) }
321
452
  let item = items[indexPath.item]
322
453
  if flowLayout.scrollDirection == .horizontal {
323
- return CGSize(width: 44, height: max(1, bounds.height))
454
+ return CGSize(width: 44, height: itemExtent(isChrome: item.isChrome))
324
455
  }
325
- let count = item.isChrome ? 1 : columns
326
- let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
327
- return CGSize(width: max(1, (bounds.width - spacing) / CGFloat(count)), height: 44)
456
+ return CGSize(width: itemExtent(isChrome: item.isChrome), height: 44)
328
457
  }
329
458
 
330
459
  private func evaluateEndReached() {