From ca2f39273b1b23d5b593cc4e4ab37efea05feaf2 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:43:48 +0500 Subject: [PATCH 1/2] MOBILE-323: Add the SwiftUI embedded block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тот же блок в SwiftUI: создаётся с id и высотой, ставится куда угодно, а плейсхолдер и экран ошибки задаются модификаторами на самом блоке. Слои хоста рисует обёртка, а не контейнер. Вью, отданная контейнеру через отдельный UIHostingController, не входит в дерево SwiftUI и не видит его окружения: плейсхолдер с @EnvironmentObject просто падает, а заданные хостом шрифт, цвет и локаль до него не доезжают. Поэтому контейнер получает под каждый заявленный слой прозрачную заглушку — держать место, — а красит это место SwiftUI поверх. Отсюда и снимок показа: обёртке нужен не только размер, но и текущий слой. И id, и высоту контейнер получает при создании и потом не меняет, поэтому другое значение любого из них — это другой блок, который надо собрать заново. Без явной идентичности хост, подставивший другой id, продолжал бы видеть содержимое прежнего: SwiftUI переиспользовал бы уже созданный контейнер. Координатор переставляется на свежие замыкания на каждом проходе body, а заглушки ставятся и снимаются на каждом обновлении: модификатор мог быть применён по условию, поэтому слой может появиться после первого прохода — и точно так же исчезнуть. --- .../MindboxEmbeddedBlock.swift | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift new file mode 100644 index 00000000..a8330871 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift @@ -0,0 +1,292 @@ +// +// MindboxEmbeddedBlock.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +#if canImport(SwiftUI) +import SwiftUI + +/// SwiftUI wrapper over `MindboxEmbeddedBlockView`. +/// +/// Created with the block `id` from the admin panel and the `height` the block should occupy. +/// Place it anywhere in a layout — the caller decides only the position and the width. The block +/// keeps the given height while its content is loading and shown; a block with nothing to show +/// collapses to zero height. +/// +/// Both outcomes can be customized the same way as in UIKit, through modifiers on the block +/// itself: `placeholder` replaces the stock loading shimmer, and `errorView` opts into showing a +/// failure instead of collapsing. Both stay ordinary SwiftUI views drawn in place, so they see the +/// environment of the tree they were written in — objects, fonts, locale, color scheme. +/// +/// ```swift +/// MindboxEmbeddedBlock(id: "stories", height: 104, onFail: hideSection) +/// .placeholder { StoriesSkeleton() } +/// .errorView { StoriesUnavailable() } +/// ``` +/// +/// Both modifiers return the block itself, so they come before any SwiftUI modifier: after +/// `.frame(…)` or `.padding(…)` the value is no longer a `MindboxEmbeddedBlock`. +/// +/// A collapsed block is zero points tall, but a stack still pays its spacing around it. To hand the +/// space back completely, drop the whole section from the layout in `onFail` — as in the example +/// above. +@available(iOS 13.0, *) +public struct MindboxEmbeddedBlock: View { + + private let id: String + private let height: CGFloat + private let onLoad: (() -> Void)? + private let onFail: (() -> Void)? + + private(set) var placeholderBuilder: (() -> AnyView)? + private(set) var errorBuilder: (() -> AnyView)? + + /// - Parameters: + /// - id: The block id from the admin panel. + /// - height: The height the block occupies while loading and shown. + /// - onLoad: The block content is shown and the container is visible. + /// - onFail: The block cannot be shown — a failure or an empty block. + public init(id: String, + height: CGFloat, + onLoad: (() -> Void)? = nil, + onFail: (() -> Void)? = nil) { + self.id = id + self.height = height + self.onLoad = onLoad + self.onFail = onFail + } + + /// Shows this view instead of the SDK shimmer while the block is loading. + /// + /// Called again, it replaces the previous placeholder. + public func placeholder(@ViewBuilder _ build: @escaping () -> Content) -> Self { + var block = self + block.placeholderBuilder = { AnyView(build()) } + return block + } + + /// Shows this view instead of collapsing when the block cannot be shown. + /// + /// Applies only to failures: an empty block — one with nothing behind its id — always + /// collapses, so a host cannot fill the space of a block that was never meant to be there. + public func errorView(@ViewBuilder _ build: @escaping () -> Content) -> Self { + var block = self + block.errorBuilder = { AnyView(build()) } + return block + } + + public var body: some View { + EmbeddedBlockBody(id: id, + height: height, + onLoad: onLoad, + onFail: onFail, + placeholder: placeholderBuilder, + errorContent: errorBuilder) + .id(identity) + } + + /// Идентичность блока в дереве SwiftUI. + /// + /// И `id`, и высоту контейнер получает при создании и потом не меняет, поэтому другое значение + /// любого из них — это другой блок, который надо собрать заново, а не обновление текущего. Без + /// этого хост, подставивший в блок другой id, продолжал бы видеть содержимое прежнего: SwiftUI + /// переиспользовал бы уже созданный контейнер. + var identity: Identity { + Identity(id: id, height: height) + } + + struct Identity: Hashable { + let id: String + let height: CGFloat + } +} + +/// Хранит текущий показ и рисует слои хоста поверх контейнера. +/// +/// Отдельная вью, а не тело `MindboxEmbeddedBlock`: состояние обязано сбрасываться вместе с +/// контейнером при смене id или высоты, а сбрасывает его `.id(…)` — и только у той вью, к которой +/// применён. +@available(iOS 13.0, *) +private struct EmbeddedBlockBody: View { + + let id: String + let height: CGFloat + let onLoad: (() -> Void)? + let onFail: (() -> Void)? + let placeholder: (() -> AnyView)? + let errorContent: (() -> AnyView)? + + /// Стартует с того же, с чего стартует контейнер: место занято, показан плейсхолдер. Блок + /// занимает свою высоту сразу, а не с первого отчёта от контейнера. + @State private var presentation: EmbeddedBlockPresentation + + init(id: String, + height: CGFloat, + onLoad: (() -> Void)?, + onFail: (() -> Void)?, + placeholder: (() -> AnyView)?, + errorContent: (() -> AnyView)?) { + self.id = id + self.height = height + self.onLoad = onLoad + self.onFail = onFail + self.placeholder = placeholder + self.errorContent = errorContent + _presentation = State(initialValue: EmbeddedBlockPresentation(layer: .placeholder, + height: max(0, height))) + } + + var body: some View { + ZStack { + EmbeddedBlockRepresentable(id: id, + height: height, + presentation: $presentation, + onLoad: onLoad, + onFail: onFail, + hasPlaceholder: placeholder != nil, + hasErrorView: errorContent != nil) + hostLayer + } + .frame(height: presentation.height) + } + + /// Слой хоста рисуется здесь, а не отдаётся контейнеру как `UIView` из `UIHostingController`: + /// такой контроллер не входит в дерево SwiftUI, поэтому вью внутри него не видит его окружения — + /// плейсхолдер с `@EnvironmentObject` просто падает, а заданные хостом шрифт, цвет и локаль до + /// него не доезжают. + @ViewBuilder private var hostLayer: some View { + switch presentation.layer { + case .placeholder: + if let placeholder { + placeholder() + } + case .errorView: + if let errorContent { + errorContent() + } + case .content, .nothing: + EmptyView() + } + } +} + +@available(iOS 13.0, *) +struct EmbeddedBlockRepresentable: UIViewRepresentable { + + let id: String + let height: CGFloat + + @Binding var presentation: EmbeddedBlockPresentation + + let onLoad: (() -> Void)? + let onFail: (() -> Void)? + + /// Есть ли у обёртки свой плейсхолдер и свой экран ошибки. Сами вью контейнеру не отдаются — + /// только факт: под каждый заявленный слой он получает прозрачную заглушку, чтобы держать место + /// и не рисовать своё, а красит это место SwiftUI поверх. + let hasPlaceholder: Bool + let hasErrorView: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(presentation: $presentation, onLoad: onLoad, onFail: onFail) + } + + func makeUIView(context: Context) -> MindboxEmbeddedBlockView { + let blockView = MindboxEmbeddedBlockView(id: id, height: height) + let coordinator = context.coordinator + blockView.delegate = coordinator + blockView.onPresentationChange = { presentation in + coordinator.update(presentation) + } + syncStandIns(in: blockView) + return blockView + } + + func updateUIView(_ uiView: MindboxEmbeddedBlockView, context: Context) { + // Замыкания и биндинг захватываются заново на каждый проход body, поэтому координатор надо + // переставлять на свежие, а не оставлять ему ту тройку, с которой его создали. + let coordinator = context.coordinator + coordinator.presentation = $presentation + coordinator.onLoad = onLoad + coordinator.onFail = onFail + syncStandIns(in: uiView) + } + + static func dismantleUIView(_ uiView: MindboxEmbeddedBlockView, coordinator: Coordinator) { + // Вью ушла из дерева: докладывать о слоях и исходах некому, а состояние обёртки уже + // выброшено вместе с ней. + uiView.onPresentationChange = nil + uiView.delegate = nil + } + + /// Заглушки ставятся и снимаются на каждом обновлении, а не только при создании: модификатор мог + /// быть применён по условию, поэтому слой может появиться после первого прохода — и точно так же + /// исчезнуть, и тогда держать под него место больше не за что. + func syncStandIns(in blockView: MindboxEmbeddedBlockView) { + // Уже стоящую заглушку не подменяем: назначение нового вью пересобирало бы констрейнты + // контейнера на каждом проходе body. + if hasPlaceholder { + if blockView.placeholderView == nil { + blockView.placeholderView = Self.makeStandIn() + } + } else { + blockView.placeholderView = nil + } + + if hasErrorView { + if blockView.errorView == nil { + blockView.errorView = Self.makeStandIn() + } + } else { + blockView.errorView = nil + } + } + + /// Прозрачная заглушка: контейнер держит под слой место, но ничего в нём не рисует и не + /// перехватывает касания — и то и другое дело SwiftUI-слоя поверх. + /// + /// Размером она во весь контейнер: слои он прибивает к своим четырём краям сам. На что-то + /// меньшее её не свести, да и незачем — пустой слой ничем не платит за свой размер. + private static func makeStandIn() -> UIView { + let standIn = UIView() + standIn.backgroundColor = .clear + standIn.isUserInteractionEnabled = false + return standIn + } + + final class Coordinator: MindboxEmbeddedBlockViewDelegate { + + var presentation: Binding + var onLoad: (() -> Void)? + var onFail: (() -> Void)? + + init(presentation: Binding, + onLoad: (() -> Void)?, + onFail: (() -> Void)?) { + self.presentation = presentation + self.onLoad = onLoad + self.onFail = onFail + } + + /// Пишется на следующем витке главной очереди: контейнер может доложить о смене слоя прямо + /// посреди прохода body, а менять состояние в этот момент нельзя. + func update(_ newPresentation: EmbeddedBlockPresentation) { + DispatchQueue.main.async { [weak self] in + guard let self, self.presentation.wrappedValue != newPresentation else { return } + self.presentation.wrappedValue = newPresentation + } + } + + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { + onLoad?() + } + + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) { + onFail?() + } + } +} +#endif From 378b6bab3d1dd2acc4e46212062881c773dd22af Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:44:02 +0500 Subject: [PATCH 2/2] MOBILE-323: Add tests for the SwiftUI embedded block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модификаторы, идентичность блока и мост между контейнером и обёрткой: заглушки под заявленные слои ставятся и снимаются по состоянию модификаторов, а показ и исход доезжают до обёртки. Идентичность проверяется отдельно, потому что на ней держится главное: другой id или другая высота — это другой блок, а не обновление текущего. --- .../MindboxEmbeddedBlockTests.swift | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift new file mode 100644 index 00000000..ceb8ddab --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift @@ -0,0 +1,256 @@ +// +// MindboxEmbeddedBlockTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +#if canImport(SwiftUI) +import Testing +import SwiftUI +@testable import Mindbox + +/// Логика блока живёт в UIKit-контейнере, поэтому здесь проверяется только то, что есть у +/// SwiftUI-обёртки своего: контракт модификаторов, идентичность блока в дереве и то, как обёртка +/// настраивает контейнер под слои, которые рисует сама. +@Suite("MindboxEmbeddedBlock SwiftUI wrapper", .tags(.embeddedBlocks)) +@MainActor +struct MindboxEmbeddedBlockTests { + + // MARK: - Modifiers + + /// Контракт модификаторов держится на семантике значения: модификатор обязан вернуть новый блок, + /// а не изменить тот, к которому его применили, иначе один и тот же блок, переиспользованный в + /// вёрстке с разной отделкой, тащил бы за собой чужой плейсхолдер. + @Test("Bare block has neither a placeholder nor an error view") + func bareBlockHasNoCustomViews() { + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + + #expect(block.placeholderBuilder == nil) + #expect(block.errorBuilder == nil) + } + + @Test("Placeholder modifier sets the placeholder and leaves the error view alone") + func placeholderModifierSetsOnlyThePlaceholder() { + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + .placeholder { Color.gray } + + #expect(block.placeholderBuilder != nil) + #expect(block.errorBuilder == nil) + } + + @Test("Error view modifier sets the error view and leaves the placeholder alone") + func errorViewModifierSetsOnlyTheErrorView() { + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + .errorView { Text("no stories") } + + #expect(block.errorBuilder != nil) + #expect(block.placeholderBuilder == nil) + } + + @Test("Both modifiers compose in either order") + func bothModifiersCompose() { + let placeholderFirst = MindboxEmbeddedBlock(id: "stories", height: 104) + .placeholder { Color.gray } + .errorView { Text("no stories") } + + let errorFirst = MindboxEmbeddedBlock(id: "stories", height: 104) + .errorView { Text("no stories") } + .placeholder { Color.gray } + + #expect(placeholderFirst.placeholderBuilder != nil) + #expect(placeholderFirst.errorBuilder != nil) + #expect(errorFirst.placeholderBuilder != nil) + #expect(errorFirst.errorBuilder != nil) + } + + @Test("Modifier returns a copy and does not touch the block it was applied to") + func modifierDoesNotMutateTheOriginal() { + let bare = MindboxEmbeddedBlock(id: "stories", height: 104) + + let decorated = bare + .placeholder { Color.gray } + .errorView { Text("no stories") } + + #expect(bare.placeholderBuilder == nil) + #expect(bare.errorBuilder == nil) + #expect(decorated.placeholderBuilder != nil) + #expect(decorated.errorBuilder != nil) + } + + @Test("Applying a modifier twice keeps the last view") + func repeatedModifierKeepsTheLastView() { + let log = BuildLog() + + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + .placeholder { ProbeView("first", log: log) } + .placeholder { ProbeView("second", log: log) } + + _ = block.placeholderBuilder?() + + #expect(log.tags == ["second"]) + } + + /// Отделка не меняет блок: обёртка может украсить один и тот же блок по-разному, но контейнер + /// под ней остаётся тем же и не должен пересобираться. + @Test("Modifiers do not change the block identity") + func modifiersKeepTheIdentity() { + let bare = MindboxEmbeddedBlock(id: "stories", height: 104) + + let decorated = bare.placeholder { Color.gray } + + #expect(decorated.identity == bare.identity) + } + + // MARK: - Identity + + /// `id` контейнер получает при создании и потом не меняет, поэтому другой id — это другой блок. + /// Без смены идентичности SwiftUI переиспользовал бы прежний контейнер, и хост продолжал бы + /// видеть содержимое старого блока. + @Test("Another id is another block") + func identityChangesWithTheId() { + let stories = MindboxEmbeddedBlock(id: "stories", height: 104) + let banner = MindboxEmbeddedBlock(id: "banner", height: 104) + + #expect(stories.identity != banner.identity) + } + + /// Высоту контейнер тоже получает при создании — новая высота требует нового контейнера. + @Test("Another height is another block") + func identityChangesWithTheHeight() { + let short = MindboxEmbeddedBlock(id: "stories", height: 104) + let tall = MindboxEmbeddedBlock(id: "stories", height: 208) + + #expect(short.identity != tall.identity) + } + + /// Обратная сторона: блок, у которого id и высота те же, пересобираться не должен — иначе + /// содержимое перезагружалось бы на каждое обновление вёрстки хоста. + @Test("Same id and height keep the same block") + func identityIsStableForTheSameInputs() { + let withCallback = MindboxEmbeddedBlock(id: "stories", height: 104, onLoad: {}) + let withoutCallback = MindboxEmbeddedBlock(id: "stories", height: 104) + + #expect(withCallback.identity == withoutCallback.identity) + } + + // MARK: - Host layers + + /// Свой плейсхолдер обёртка рисует сама, поэтому контейнеру достаётся прозрачная заглушка: иначе + /// под плейсхолдером хоста остался бы виден шиммер SDK. + @Test("Custom placeholder replaces the SDK shimmer with a transparent stand-in") + func customPlaceholderReplacesTheShimmer() throws { + let blockView = makeBlockView() + + makeRepresentable(hasPlaceholder: true).syncStandIns(in: blockView) + + let standIn = try #require(blockView.placeholderView) + #expect(standIn.superview === blockView) + #expect(blockView.subviews.contains { $0 is EmbeddedBlockShimmerView } == false) + // Касания достаются SwiftUI-слою поверх, а не заглушке под ним. + #expect(standIn.isUserInteractionEnabled == false) + } + + @Test("Block without a custom placeholder keeps the SDK shimmer") + func bareBlockKeepsTheShimmer() { + let blockView = makeBlockView() + + makeRepresentable().syncStandIns(in: blockView) + + #expect(blockView.placeholderView == nil) + #expect(blockView.subviews.contains { $0 is EmbeddedBlockShimmerView }) + } + + /// Держать высоту на провале контейнер соглашается только по назначенному `errorView`, поэтому + /// заглушка нужна и здесь — иначе SwiftUI-экран ошибки рисовался бы в схлопнутом блоке. + @Test("Custom error view opts the container into showing the failure") + func customErrorViewOptsIntoShowingTheFailure() { + let blockView = makeBlockView() + + makeRepresentable(hasErrorView: true).syncStandIns(in: blockView) + + #expect(blockView.errorView != nil) + } + + @Test("Block without a custom error view leaves the container collapsing") + func bareBlockLeavesTheContainerCollapsing() { + let blockView = makeBlockView() + + makeRepresentable().syncStandIns(in: blockView) + + #expect(blockView.errorView == nil) + } + + /// Модификатор мог быть применён по условию: слой, появившийся после создания блока, обязан + /// доехать до контейнера, а исчезнувший — перестать держать под себя место. + @Test("Layers added and dropped after creation take effect") + func layersAddedAndDroppedAfterCreationTakeEffect() { + let blockView = makeBlockView() + makeRepresentable().syncStandIns(in: blockView) + + makeRepresentable(hasPlaceholder: true, hasErrorView: true).syncStandIns(in: blockView) + + #expect(blockView.placeholderView != nil) + #expect(blockView.errorView != nil) + + makeRepresentable().syncStandIns(in: blockView) + + #expect(blockView.placeholderView == nil) + #expect(blockView.errorView == nil) + } + + /// Обновление без изменений не должно стоить контейнеру пересборки слоёв и констрейнтов: + /// `updateUIView` вызывается на каждый проход body хоста. + @Test("Repeated updates keep the very same stand-ins") + func repeatedUpdatesKeepTheSameStandIns() throws { + let blockView = makeBlockView() + let representable = makeRepresentable(hasPlaceholder: true, hasErrorView: true) + representable.syncStandIns(in: blockView) + let placeholder = try #require(blockView.placeholderView) + let errorView = try #require(blockView.errorView) + + representable.syncStandIns(in: blockView) + + #expect(blockView.placeholderView === placeholder) + #expect(blockView.errorView === errorView) + } + + // MARK: - Helpers + + /// Контейнер с подменёнными зависимостями: обёртка сама ничего не грузит, её дело — правильно + /// настроить контейнер, поэтому окно и живой контент здесь не нужны. + private func makeBlockView() -> MindboxEmbeddedBlockView { + MindboxEmbeddedBlockView(id: "stories", + height: 104, + contentProvider: EmbeddedBlockTestBed().provider) + } + + private func makeRepresentable(hasPlaceholder: Bool = false, + hasErrorView: Bool = false) -> EmbeddedBlockRepresentable { + let presentation = EmbeddedBlockPresentation(layer: .placeholder, height: 104) + return EmbeddedBlockRepresentable(id: "stories", + height: 104, + presentation: .constant(presentation), + onLoad: nil, + onFail: nil, + hasPlaceholder: hasPlaceholder, + hasErrorView: hasErrorView) + } +} + +/// Какие вью на самом деле собрал блок. `AnyView` снаружи не разглядеть, поэтому отметку оставляет +/// сама вью в момент создания. +private final class BuildLog { + var tags: [String] = [] +} + +private struct ProbeView: View { + + init(_ tag: String, log: BuildLog) { + log.tags.append(tag) + } + + var body: some View { Color.clear } +} +#endif