Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ extension BuilderViewProtocol {
func getFinalStyle(responsiveStyles: BuilderBlockResponsiveStyles?) -> [String: String] {
return CSSStyleUtil.getFinalStyle(responsiveStyles: responsiveStyles)
}

func codeBindings() -> [String: String]? {
return nil
}
}

struct BuilderEmptyView: BuilderViewProtocol {
Expand Down
8 changes: 3 additions & 5 deletions Sources/BuilderIO/Components/BuilderBlock.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import SwiftUI

//BuilderBlock forms the out layout container for all components mimicking Blocks from response. As blocks can have layout direction of either horizontal or vertical a check is made and layout selected.

//BuilderBlock forms the out layout container for all components mimicking Blocks from response. As blocks can have layout direction of either horizontal or vertical a check is made and layout selected.
//BuilderBlock forms the outer layout container for all components mimicking Blocks from response. As blocks can have layout direction of either horizontal or vertical a check is made and layout selected.

enum BuilderLayoutDirection {
case horizontal
Expand Down Expand Up @@ -33,11 +31,11 @@ struct BuilderBlock: View {
if builderLayoutDirection == .parentLayout {
blockContent()
} else if builderLayoutDirection == .horizontal {
HStack(spacing: spacing) { // Adjust alignment and spacing as needed
LazyHStack(spacing: spacing) { // Adjust alignment and spacing as needed
blockContent()
}
} else { // Default to column
VStack(spacing: spacing) { // Adjust alignment and spacing as needed
LazyVStack(spacing: spacing) { // Adjust alignment and spacing as needed
blockContent()
}
}
Expand Down
13 changes: 12 additions & 1 deletion Sources/BuilderIO/Components/BuilderColumns.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ struct BuilderColumns: BuilderViewProtocol {
let elementData = try JSONEncoder().encode(anyCodableElement)

// Decode that Data into a BuilderContentData instance
let column = try decoder.decode(BuilderContentData.self, from: elementData)
var column = try decoder.decode(BuilderContentData.self, from: elementData)
decodedColumns.append(column)
} catch {
// Handle error for a specific element if it can't be decoded
Expand All @@ -41,6 +41,17 @@ struct BuilderColumns: BuilderViewProtocol {
// or simply skip this element, as we are doing here.
}
}

if let stateBoundObjectModel = block.stateBoundObjectModel {
for columnIndex in decodedColumns.indices {
for blockIndex in decodedColumns[columnIndex].blocks.indices {
decodedColumns[columnIndex].blocks[blockIndex]
.propagateStateBoundObjectModel(
stateBoundObjectModel, stateRepeatCollectionKey: block.stateRepeatCollectionKey)
}
}
}

self.columns = decodedColumns

} else {
Expand Down
43 changes: 28 additions & 15 deletions Sources/BuilderIO/Components/BuilderImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct BuilderImage: BuilderViewProtocol {
var contentMode: ContentMode = .fit
var fitContent: Bool = false

@State private var imageLoadedSuccessfully: Bool = false
@State private var builderImageLoader: BuilderImageLoader = BuilderImageLoader()

init(block: BuilderBlockModel) {
self.block = block
Expand All @@ -30,16 +30,27 @@ struct BuilderImage: BuilderViewProtocol {
(block.component?.options?.dictionaryValue?["fitContent"]?.boolValue ?? false)
&& !(block.children?.isEmpty ?? true)

if let imageBinding = block.codeBindings(for: "image")?.stringValue {
self.imageURL = URL(
string: imageBinding)
}

}

var body: some View {

AsyncImage(url: imageURL) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
Group {
switch builderImageLoader.imageStatus {
case .loading:
Rectangle()
.fill(Color.clear)
.aspectRatio(self.aspectRatio ?? 1, contentMode: self.contentMode)
.overlay(ProgressView())
case .error:
Rectangle()
.fill(Color.clear)
case .loaded(let uiImage):
if fitContent {
// Content fits over the image, image acts as background
Group {
if let children = children, !children.isEmpty {
VStack(spacing: 0) {
Expand All @@ -50,20 +61,22 @@ struct BuilderImage: BuilderViewProtocol {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(0)
.background(
image.resizable()
Image(uiImage: uiImage) // Use UIImage directly
.resizable()
.aspectRatio(self.aspectRatio ?? 1, contentMode: self.contentMode)
.clipped()
)
} else {
EmptyView()
EmptyView() // No children, so nothing to show if fitContent is true without children
}
}

} else {
// Image fills the space, children overlay it
Rectangle().fill(Color.clear)
.aspectRatio(self.aspectRatio ?? 1, contentMode: self.contentMode)
.background(
image.resizable()
Image(uiImage: uiImage) // Use UIImage directly
.resizable()
.aspectRatio(contentMode: self.contentMode)
.clipped()
)
Expand All @@ -80,12 +93,12 @@ struct BuilderImage: BuilderViewProtocol {
)
}

case .failure:
EmptyView()
@unknown default:
EmptyView()
}
}
.task {
try? await Task.sleep(nanoseconds: 10_000_000)
await builderImageLoader.loadImage(from: imageURL)
}

}
}
87 changes: 87 additions & 0 deletions Sources/BuilderIO/Components/BuilderImageLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import SwiftUI
import UIKit // Required for UIImage and URLSession

enum ImageStatus {
case loading
case loaded(UIImage)
case error
}

@MainActor
@Observable
class BuilderImageLoader {

// MARK: - Static Cache Properties
// Static a single, shared cache across all uses of BuilderImageLoader.
private static let imageCache = NSCache<NSString, CachedImageEntry>()
private static let cacheDuration: TimeInterval = 5 * 60 // Cache duration in seconds (5 minutes)

public var imageStatus: ImageStatus = .loading

private class CachedImageEntry {
let image: UIImage
let timestamp: Date

init(image: UIImage, timestamp: Date) {
self.image = image
self.timestamp = timestamp
}

/// Checks if the cached image is still valid based on the cache duration.
func isValid(for duration: TimeInterval) -> Bool {
return Date().timeIntervalSince(timestamp) < duration
}
}

func loadImage(from url: URL?) async {
guard let url = url else {
print("BuilderImageLoader: Invalid URL provided.")
imageStatus = .error
return
}

let cacheKey = url.absoluteString as NSString

// 1. Check if the image is in the static cache and is still valid
if let cachedEntry = BuilderImageLoader.imageCache.object(forKey: cacheKey),
cachedEntry.isValid(for: BuilderImageLoader.cacheDuration)
{
print("BuilderImageLoader: Image loaded from static cache for URL: \(url)")
imageStatus = .loaded(cachedEntry.image)
return
}

// 2. If not in cache or expired, download the image
print("BuilderImageLoader: Downloading image for URL: \(url)")
do {
let (data, response) = try await URLSession.shared.data(from: url)

guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode)
else {
print("BuilderImageLoader: Server error or invalid response for URL: \(url)")
imageStatus = .error
return
}

guard let image = UIImage(data: data) else {
print("BuilderImageLoader: Could not create image from data for URL: \(url)")
imageStatus = .error
return
}

// 3. Cache the newly downloaded image in the static cache
let newCacheEntry = CachedImageEntry(image: image, timestamp: Date())
BuilderImageLoader.imageCache.setObject(newCacheEntry, forKey: cacheKey)
print("BuilderImageLoader: Image downloaded and cached in static cache for URL: \(url)")

imageStatus = .loaded(image)

} catch {
print(
"BuilderImageLoader: Failed to download image from URL: \(url), Error: \(error.localizedDescription)"
)
imageStatus = .error
}
}
}
4 changes: 4 additions & 0 deletions Sources/BuilderIO/Components/BuilderText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ struct BuilderText: BuilderViewProtocol {
self.block = block
self.text = block.component?.options?.dictionaryValue?["text"]?.stringValue ?? ""
self.responsiveStyles = getFinalStyle(responsiveStyles: block.responsiveStyles)

if let textBinding = block.codeBindings(for: "text") {
self.text = textBinding.stringValue
}
}

var body: some View {
Expand Down
18 changes: 10 additions & 8 deletions Sources/BuilderIO/ExportedView/BuilderIOContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ public struct BuilderIOContentView: View {
let model: String
let url: String?

@StateObject private var viewModel: BuilderIOViewModel
@State private var viewModel: BuilderIOViewModel

public init(model: String) {
self.model = model
self.url = nil
_viewModel = StateObject(wrappedValue: BuilderIOViewModel())
_viewModel = State(wrappedValue: BuilderIOViewModel())
}

init(url: String, model: String = "page") {
self.url = url
self.model = model
_viewModel = StateObject(wrappedValue: BuilderIOViewModel())
_viewModel = State(wrappedValue: BuilderIOViewModel())
}

public var body: some View {
Expand All @@ -29,12 +29,14 @@ public struct BuilderIOContentView: View {
.foregroundColor(.red)
} else if let builderContent = viewModel.builderContent {

let builderBlockView = BuilderBlock(blocks: builderContent.data.blocks)
.onAppear {
if !BuilderContentAPI.isPreviewing() {
viewModel.sendTrackingPixel()
}
let builderBlockView = BuilderBlock(
blocks: builderContent.data.blocks, builderLayoutDirection: .vertical
)
.onAppear {
if !BuilderContentAPI.isPreviewing() {
viewModel.sendTrackingPixel()
}
}

// Conditionally apply ScrollView and refreshable
if url != nil && !url!.isEmpty {
Expand Down
Loading
Loading