// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import ListViewKit import SwiftUI import UIKit private struct MobileListRowIsDisplayedKey: EnvironmentKey { static let defaultValue = true } private struct MobileListReduceMotionKey: EnvironmentKey { static let defaultValue = false } private struct MobileListSceneIsActiveKey: EnvironmentKey { static let defaultValue = true } extension EnvironmentValues { var mobileListReduceMotion: Bool { get { self[MobileListReduceMotionKey.self] } set { self[MobileListReduceMotionKey.self] = newValue } } var mobileListSceneIsActive: Bool { get { self[MobileListSceneIsActiveKey.self] } set { self[MobileListSceneIsActiveKey.self] = newValue } } var mobileListRowIsDisplayed: Bool { get { self[MobileListRowIsDisplayedKey.self] } set { self[MobileListRowIsDisplayedKey.self] = newValue } } } enum MobileNativeListSurface: Hashable { case grouped case plain var listBackground: UIColor { switch self { case .grouped: .systemGroupedBackground case .plain: .systemBackground } } var rowBackground: UIColor { switch self { case .grouped: .secondarySystemGroupedBackground case .plain: .systemBackground } } } struct MobileNativeList: UIViewRepresentable where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { typealias UIViewType = ListView let items: [Item] let estimatedRowHeight: CGFloat let refreshID: AnyHashable let onSelect: ((Item) -> Void)? let onRefresh: (() async -> Void)? let followsBottom: Bool let bottomContentInset: CGFloat let surface: MobileNativeListSurface let rowRevision: ((Item) -> UInt64)? /// Refreshes mounted content without invalidating geometry; use rowRevision for layout changes. let interactionRevision: ((Item) -> UInt64)? let prepareUpdate: (([Item], [Item], Set) -> Void)? let onViewportChange: ((Set) -> Void)? let onTap: (() -> Void)? @ViewBuilder let rowContent: (Item) -> RowContent @Environment(\.colorScheme) private var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.locale) private var locale @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.mobileListReduceMotion) private var reduceMotionOverride @Environment(\.scenePhase) private var scenePhase init( items: [Item], estimatedRowHeight: CGFloat = 72, refreshID: AnyHashable = 0, onSelect: ((Item) -> Void)? = nil, onRefresh: (() async -> Void)? = nil, followsBottom: Bool = false, bottomContentInset: CGFloat = 0, surface: MobileNativeListSurface = .grouped, rowRevision: ((Item) -> UInt64)? = nil, interactionRevision: ((Item) -> UInt64)? = nil, prepareUpdate: (([Item], [Item], Set) -> Void)? = nil, onViewportChange: ((Set) -> Void)? = nil, onTap: (() -> Void)? = nil, @ViewBuilder rowContent: @escaping (Item) -> RowContent ) { self.items = items self.estimatedRowHeight = estimatedRowHeight self.refreshID = refreshID self.onSelect = onSelect self.onRefresh = onRefresh self.followsBottom = followsBottom self.bottomContentInset = bottomContentInset self.surface = surface self.rowRevision = rowRevision self.interactionRevision = interactionRevision self.prepareUpdate = prepareUpdate self.onViewportChange = onViewportChange self.onTap = onTap self.rowContent = rowContent } func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> ListView { let listView = ListView() let coordinator = context.coordinator listView.backgroundColor = surface.listBackground listView.estimatedRowHeight = estimatedRowHeight listView.keyboardDismissMode = .interactive listView.rows { ListRow(MobileHostingListRow.self) .estimatedHeight(estimatedRowHeight) .configure { row, item, rowContext in if rowContext.purpose == .display { coordinator.configure(row, item: item, surface: surface) } else { coordinator.configurePrototype(row, item: item, surface: surface) } } } coordinator.installTapRecognizer(on: listView) coordinator.observeViewport(of: listView) return listView } func updateUIView(_ listView: ListView, context: Context) { if listView.contentInset.bottom != bottomContentInset { listView.contentInset.bottom = bottomContentInset } context.coordinator.update( listView: listView, items: items, renderer: { item in AnyView(rowContent(item)) }, onSelect: onSelect, onRefresh: onRefresh, followsBottom: followsBottom, surface: surface, refreshID: refreshID, locale: locale, colorScheme: colorScheme, dynamicTypeSize: dynamicTypeSize, reduceMotion: reduceMotion || reduceMotionOverride, sceneIsActive: scenePhase == .active, rowRevision: rowRevision, interactionRevision: interactionRevision, prepareUpdate: prepareUpdate, onViewportChange: onViewportChange, onTap: onTap ) } static func dismantleUIView(_: ListView, coordinator: Coordinator) { coordinator.cancelRefresh() coordinator.stopObservingViewport() } @MainActor final class Coordinator: NSObject, UIGestureRecognizerDelegate { typealias Renderer = @MainActor (Item) -> AnyView private var items: [Item] = [] private var renderer: Renderer? private var onSelect: ((Item) -> Void)? private var onRefresh: (() async -> Void)? private var refreshTask: Task? private weak var refreshControl: UIRefreshControl? private var locale = Locale.current private var colorScheme = ColorScheme.light private var dynamicTypeSize = DynamicTypeSize.large private var refreshID: AnyHashable = 0 private var reduceMotion = false private var sceneIsActive = true private var revisions: [Item.ID: UInt64] = [:] private var interactionRevisions: [Item.ID: UInt64] = [:] private var configuredIDsDuringUpdate: Set? private var onViewportChange: ((Set) -> Void)? private var onTap: (() -> Void)? private weak var listView: ListView? private var observations: [NSKeyValueObservation] = [] private var viewportCheckScheduled = false func observeViewport(of view: ListView) { listView = view observations = [ view.observe(\.bounds) { [weak self] _, _ in MainActor.assumeIsolated { self?.scheduleViewportCheck() } }, view.observe(\.contentSize) { [weak self] _, _ in MainActor.assumeIsolated { self?.scheduleViewportCheck() } }, ] } func installTapRecognizer(on view: ListView) { let tap = UITapGestureRecognizer(target: self, action: #selector(listTapped)) tap.cancelsTouchesInView = false tap.delegate = self view.addGestureRecognizer(tap) } func stopObservingViewport() { observations.removeAll() onViewportChange = nil onTap = nil listView = nil } private func visibleIDs(in view: ListView) -> Set { Set(view.indicesForVisibleRows.compactMap { items.indices.contains($0) ? items[$0].id : nil }) } private func scheduleViewportCheck() { guard !viewportCheckScheduled else { return } viewportCheckScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } viewportCheckScheduled = false guard let view = listView else { return } onViewportChange?(visibleIDs(in: view)) } } func update( listView: ListView, items newItems: [Item], renderer: @escaping Renderer, onSelect: ((Item) -> Void)?, onRefresh: (() async -> Void)?, followsBottom: Bool, surface: MobileNativeListSurface, refreshID newRefreshID: AnyHashable, locale: Locale, colorScheme: ColorScheme, dynamicTypeSize: DynamicTypeSize, reduceMotion: Bool = false, sceneIsActive: Bool = true, rowRevision: ((Item) -> UInt64)? = nil, interactionRevision: ((Item) -> UInt64)? = nil, prepareUpdate: (([Item], [Item], Set) -> Void)? = nil, onViewportChange: ((Set) -> Void)? = nil, onTap: (() -> Void)? = nil ) { configuredIDsDuringUpdate = interactionRevision == nil ? nil : [] defer { configuredIDsDuringUpdate = nil } let wasEmpty = items.isEmpty let wasAtBottom = wasEmpty || listView.isScrolledToBottom(tolerance: 48) let environmentChanged = self.locale != locale || self.colorScheme != colorScheme || self.dynamicTypeSize != dynamicTypeSize || refreshID != newRefreshID || self.reduceMotion != reduceMotion || self.sceneIsActive != sceneIsActive self.renderer = renderer self.onSelect = onSelect self.onRefresh = onRefresh self.onViewportChange = onViewportChange self.onTap = onTap self.locale = locale self.colorScheme = colorScheme self.dynamicTypeSize = dynamicTypeSize self.reduceMotion = reduceMotion self.sceneIsActive = sceneIsActive refreshID = newRefreshID listView.backgroundColor = surface.listBackground updateRefreshControl(in: listView) prepareUpdate?(items, newItems, visibleIDs(in: listView)) let newRevisions = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, rowRevision?($0) ?? 0) }) let newInteractionRevisions = interactionRevision.map { revision in Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, revision($0)) }) } ?? [:] let previous = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) let changed = newItems.filter { previous[$0.id] != $0 || revisions[$0.id] != newRevisions[$0.id] } let identitiesChanged = items.map(\.id) != newItems.map(\.id) if identitiesChanged { if !wasEmpty, newItems.count >= items.count, newItems.prefix(items.count).map(\.id) == items.map(\.id) { listView.append(contentsOf: Array(newItems.dropFirst(items.count))) } else { listView.apply(newItems, animated: !wasEmpty && !reduceMotion) } } // A disclosure can change geometry without changing the item value. for item in changed where previous[item.id] != nil { if !listView.update(item), let row = listView.rowView(for: item.id) as? MobileHostingListRow { configure(row, item: item, surface: surface) } listView.invalidateLayout(forRowWith: item.id) } if environmentChanged { listView.reloadData() } // Interaction-only updates retain the host and its measured geometry. Reconciliation // may already have configured a row with the latest renderer, so do not configure twice. if interactionRevision != nil, !environmentChanged { for item in newItems where interactionRevisions[item.id] != newInteractionRevisions[item.id] && configuredIDsDuringUpdate?.contains(item.id) != true { if let row = listView.rowView(for: item.id) as? MobileHostingListRow { configure(row, item: item, surface: surface) } } } items = newItems revisions = newRevisions interactionRevisions = newInteractionRevisions scheduleViewportCheck() if followsBottom, wasAtBottom, identitiesChanged || !changed.isEmpty { DispatchQueue.main.async { [weak listView] in guard let listView, !listView.isUserInteractingWithScroll else { return } listView.layoutIfNeeded() listView.scrollToBottom(animated: !wasEmpty && !reduceMotion) } } } func cancelRefresh() { refreshTask?.cancel() refreshTask = nil refreshControl?.endRefreshing() } func gestureRecognizer( _: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer ) -> Bool { true } @objc func listTapped() { onTap?() } private func updateRefreshControl(in listView: ListView) { if onRefresh == nil { cancelRefresh() listView.refreshControl = nil refreshControl = nil return } guard listView.refreshControl == nil else { return } let control = UIRefreshControl() control.addTarget(self, action: #selector(refreshRequested), for: .valueChanged) listView.refreshControl = control refreshControl = control } @objc private func refreshRequested() { guard refreshTask == nil, let onRefresh else { refreshControl?.endRefreshing() return } refreshTask = Task { [weak self] in await onRefresh() guard let self else { return } refreshControl?.endRefreshing() refreshTask = nil } } func configure(_ row: MobileHostingListRow, item: Item, surface: MobileNativeListSurface) { configuredIDsDuringUpdate?.insert(item.id) row.configure( rootView: rootView(for: item), selectable: onSelect != nil, backgroundColor: surface.rowBackground ) } func configurePrototype(_ row: MobileHostingListRow, item: Item, surface: MobileNativeListSurface) { row.configure( rootView: rootView(for: item, displayed: false), selectable: false, backgroundColor: surface.rowBackground) } private func rootView(for item: Item, displayed: Bool = true) -> AnyView { guard let renderer else { return AnyView(EmptyView()) } let content = AnyView( renderer(item) .environment(\.locale, locale) .environment(\.colorScheme, colorScheme) .environment(\.dynamicTypeSize, dynamicTypeSize) .environment(\.mobileListReduceMotion, reduceMotion) .environment(\.mobileListSceneIsActive, sceneIsActive) .environment(\.mobileListRowIsDisplayed, displayed) .id(item.id) ) guard displayed, let onSelect else { return content } return AnyView(content.onTapGesture { onSelect(item) }) } } } @MainActor final class MobileHostingListRow: ListRowView, UIGestureRecognizerDelegate { private let contentContainer = UIView() private var hostedView: UIView? private var selectable = false private var pressOrigin: CGPoint? private var restingBackgroundColor = UIColor.clear override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = true backgroundColor = .clear contentContainer.backgroundColor = .clear contentContainer.translatesAutoresizingMaskIntoConstraints = false addSubview(contentContainer) NSLayoutConstraint.activate([ contentContainer.leadingAnchor.constraint(equalTo: leadingAnchor), contentContainer.trailingAnchor.constraint(equalTo: trailingAnchor), contentContainer.topAnchor.constraint(equalTo: topAnchor), contentContainer.bottomAnchor.constraint(equalTo: bottomAnchor), ]) let press = UILongPressGestureRecognizer(target: self, action: #selector(rowPressed(_:))) press.minimumPressDuration = 0 press.cancelsTouchesInView = false press.delegate = self contentContainer.addGestureRecognizer(press) } override func prepareForReuse() { super.prepareForReuse() hostedView?.removeFromSuperview() hostedView = nil selectable = false pressOrigin = nil restingBackgroundColor = .clear backgroundColor = .clear contentContainer.backgroundColor = .clear } func configure(rootView: AnyView, selectable: Bool, backgroundColor: UIColor) { restingBackgroundColor = backgroundColor self.backgroundColor = backgroundColor contentContainer.backgroundColor = backgroundColor let configuration = UIHostingConfiguration { rootView } .margins(.all, 0) .background(.clear) if let contentView = hostedView as? any UIContentView { contentView.configuration = configuration self.selectable = selectable return } let hostedView = configuration.makeContentView() hostedView.translatesAutoresizingMaskIntoConstraints = false contentContainer.addSubview(hostedView) NSLayoutConstraint.activate([ hostedView.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), hostedView.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), hostedView.topAnchor.constraint(equalTo: contentContainer.topAnchor), hostedView.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor), ]) self.hostedView = hostedView self.selectable = selectable } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UILongPressGestureRecognizer { return selectable } return true } func gestureRecognizer( _: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer ) -> Bool { true } @objc private func rowPressed(_ recognizer: UILongPressGestureRecognizer) { let isPressed: Bool switch recognizer.state { case .began: pressOrigin = recognizer.location(in: contentContainer) isPressed = true case .changed: let location = recognizer.location(in: contentContainer) let distance = pressOrigin.map { hypot(location.x - $0.x, location.y - $0.y) } ?? .infinity isPressed = distance < 10 && contentContainer.bounds.insetBy(dx: -12, dy: -12).contains(location) default: pressOrigin = nil isPressed = false } UIView.animate( withDuration: 0.12, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction] ) { self.contentContainer.backgroundColor = isPressed ? .tertiarySystemFill : self.restingBackgroundColor } } }