I am constructing an app utilizing SwiftUI that shows some playing cards:
- The cardboard views are stacked on high of each other like playing cards in a deck, however solely the highest 3 or so playing cards are seen.
- The highest-most card may be swiped away by the consumer, exposing the cardboard beneath as it’s swiped.
- The eliminated card is then repopulated with new card knowledge, and is put on the backside of the stack.
- The cardboard views are construct utilizing a WebView (created as a wrapper round WKWebView in WebKit). That is achieved to permit displaying HTML content material and styling utilizing CSS.
- The info objects that the cardboard views are primarily based on might quantity from 1 to doubtlessly hundreds.
I began by creating the CardView primarily based on the WebView, and this appears to work effectively sufficient. I then stacked 3 of these card views in a z-stack and loaded them with the primary three knowledge objects.
- I made it so the top-most card may be dragged with a drag gesture.
- When it’s dragged far sufficient it snaps again to it unique place (no animation)
- Then the app increments an index, which causes all 3 playing cards within the stack to replace to the following knowledge merchandise.
Right here is the a part of the code that reveals how I created the three card views in a z-stack and carry out the incrementing:
struct ContentView: View {
let sampleItems = SampleDataGenerator.generateSampleData()
@State personal var currentItemIndex = 0
@State personal var dragOffset: CGSize = .zero
// Each time currentItemIndex adjustments then present, subsequent, and third objects replace
personal var currentItem: SampleItem? {
guard currentItemIndex >= 0 else { return nil }
guard currentItemIndex <= sampleItems.rely - 1 else { return nil}
return sampleItems[currentItemIndex]
}
personal var nextItem: SampleItem? {
guard currentItemIndex >= 0 else { return nil }
guard currentItemIndex + 1 <= sampleItems.rely - 1 else { return nil }
let nextItemIndex = (currentItemIndex + 1) % (sampleItems.rely)
return sampleItems[nextItemIndex]
}
personal var thirdItem: SampleItem? {
guard currentItemIndex >= 0 else { return nil }
guard currentItemIndex + 2 <= sampleItems.rely - 1 else { return nil }
let thirdItemIndex = (currentItemIndex + 2) % (sampleItems.rely)
return sampleItems[thirdItemIndex]
}
var physique: some View {
ZStack {
CardView(cardText: thirdItem?.title, cardImageUrl: thirdItem?.imagePath)
.body(width: 300, top: 300)
.rotationEffect(Angle(levels: 2.0))
CardView(cardText: nextItem?.title, cardImageUrl: nextItem?.imagePath)
.body(width: 300, top: 300)
.rotationEffect(Angle(levels: 1.0))
// This card view is the one one which the consumer interacts with
CardView(cardText: currentItem?.title, cardImageUrl: currentItem?.imagePath)
.body(width: 300, top: 300)
.rotationEffect(Angle(levels: 0.0))
.offset(x: dragOffset.width * 5, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
dragOffset = gesture.translation
}
.onEnded { gesture in
dragOffset = .zero
if abs(gesture.translation.width) > 50 {
// Snap card again to begin place, and show subsequent card
dragOffset = .zero
currentItemIndex = (currentItemIndex + 1) % (sampleItems.rely)
} else {
// Card wasn't dragged sufficient, so animate snapping again to begin place
withAnimation() {
dragOffset = .zero
}
}
}
)
}
}
}
This appeared to work okay after I was initially testing with out utilizing the WebView and was simply utilizing a easy Textual content view. Nonetheless, as soon as I began utilizing the WebView, I seen that there was a flicker or flash of the earlier card seen simply as the following card was made seen after dragging the highest card out of the way in which. I think that utilizing a WebView introduces a slight delay in rendering inflicting the flash.
Here’s a GIF displaying the flash of the previous card when a brand new one is proven:
card flash instance
This did get me considering that the easiest way to deal with that is to have the three playing cards in a sort of carousel of types, and because the high card is dragged away, it ‘rotates’ to the rear of the stack, after which masses new knowledge.
I discovered a stackoverflow put up displaying how a carousel might be carried out utilizing SwiftUI that I believed I might modify to make work for my stack of playing cards.
Swift UI carousel instance on stackoverflow
Nonetheless, the difficulty I run into with that instance is that each one the information objects are loaded upfront into the carousel. In my case, I’d need to solely present the highest subsequent 2 or 3 objects. Loading doubtlessly hundreds of things into card views, every containing an online view, shouldn’t be sensible.
So right here I am caught and hoping somebody has the expertise and information to assist me out.
Right here is my pattern code in full. It has been simplified to its most simple type simply for example the difficulty.
It may be run by creating a brand new iOS software in Xcode, setting the goal to iOS 16.x, and changing all of the textual content within the ContentView.swift file with the next. The difficulty may be noticed within the simulator by dragging the highest card left or proper. I’ve additionally examined utilizing an precise {hardware} system and the difficulty is there as effectively.
import SwiftUI
import WebKit
struct ContentView: View {
let sampleItems = SampleDataGenerator.generateSampleData()
@State personal var currentItemIndex = 0
@State personal var dragOffset: CGSize = .zero
// Each time currentItemIndex adjustments then present, subsequent, and third objects replace
personal var currentItem: SampleItem? {
guard currentItemIndex >= 0 else { return nil }
guard currentItemIndex <= sampleItems.rely - 1 else { return nil}
return sampleItems[currentItemIndex]
}
personal var nextItem: SampleItem? {
guard currentItemIndex >= 0 else { return nil }
guard currentItemIndex + 1 <= sampleItems.rely - 1 else { return nil }
let nextItemIndex = (currentItemIndex + 1) % (sampleItems.rely)
return sampleItems[nextItemIndex]
}
personal var thirdItem: SampleItem? {
guard currentItemIndex >= 0 else { return nil }
guard currentItemIndex + 2 <= sampleItems.rely - 1 else { return nil }
let thirdItemIndex = (currentItemIndex + 2) % (sampleItems.rely)
return sampleItems[thirdItemIndex]
}
var physique: some View {
ZStack {
CardView(cardText: thirdItem?.title, cardImageUrl: thirdItem?.imagePath)
.body(width: 300, top: 300)
.rotationEffect(Angle(levels: 2.0))
CardView(cardText: nextItem?.title, cardImageUrl: nextItem?.imagePath)
.body(width: 300, top: 300)
.rotationEffect(Angle(levels: 1.0))
// This card view is the one one which the consumer interacts with
CardView(cardText: currentItem?.title, cardImageUrl: currentItem?.imagePath)
.body(width: 300, top: 300)
.rotationEffect(Angle(levels: 0.0))
.offset(x: dragOffset.width * 5, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
dragOffset = gesture.translation
}
.onEnded { gesture in
dragOffset = .zero
if abs(gesture.translation.width) > 50 {
// Snap card again to begin place, and show subsequent card
dragOffset = .zero
currentItemIndex = (currentItemIndex + 1) % (sampleItems.rely)
} else {
// Card wasn't dragged sufficient, so animate snapping again to begin place
withAnimation() {
dragOffset = .zero
}
}
}
)
}
}
}
struct CardView: View {
let cardText: String?
let cardImageUrl: String?
let baseUrl = URL(fileURLWithPath: NSTemporaryDirectory())
func cardHtml() -> String {
let fashion = "physique {font-family: arial; font-size: 24px; line-height: 100%; text-align: middle; colour: black; background-color: white; margin: 0; top: 100%; overflow: hidden;} img {width: 75%;}"
return "<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta title="viewport" content material="width=device-width"><fashion>(fashion)</fashion></head><physique><div>(cardText ?? "")</div><div><img src="(cardImageUrl ?? "")"></div></physique></html>"
}
var physique: some View {
if (cardText == nil) {
EmptyView()
} else {
ZStack {
Rectangle()
.fill(Coloration.white)
.border(.black)
.shadow(radius: 4.0, y: 4.0)
// If I exploit textual content as a substitute of WebView, then no flickering
// Textual content(cardText ?? "")
// WebView takes a little bit of time to load, so there's flickering
WebView(content material: cardHtml(), baseURL: baseUrl)
.padding(20)
.multilineTextAlignment(.middle)
}
}
}
}
struct WebView: UIViewRepresentable {
let content material: String?
let baseURL: URL?
public func makeUIView(context: Context) -> WKWebView {
let view = WKWebView()
guard let content material = content material else { return view }
view.loadHTMLString(content material, baseURL: baseURL)
return view
}
public func updateUIView(_ uiView: WKWebView, context: Context) {
guard let content material = content material else { return }
uiView.loadHTMLString(content material, baseURL: baseURL)
}
}
// MARK: - The next code is only for producing temp knowledge for this pattern
struct SampleItem {
var title: String
var imagePath: String
}
struct SampleDataGenerator {
static let tempUrl = FileManager().temporaryDirectory.appendingPathComponent("temp")
static let symbols = ["teddybear.fill", "pawprint.fill", "bird.fill", "tortoise.fill", "fish.fill", "paperplane.fill", "trophy.fill", "medal.fill", "dice.fill", "hare.fill", "lizard.fill"]
static func generateSampleData() -> [SampleItem] {
var sampleData: [SampleItem] = []
let colours:[UIColor] = [.red, .orange, .yellow, .green, .blue, .purple, .brown, .black, .cyan, .lightGray, .magenta]
setupTempDir()
for i in 0..<10 {
let title = "(i) (symbols[i].cut up(separator: ".")[0])"
let colour = UIColor.random(from: colours)!
let filePath = makeTempImage(symbolName: symbols[i], colour: colour)
let merchandise = SampleItem(title: title, imagePath: filePath)
sampleData.append(merchandise)
}
return sampleData
}
static func setupTempDir() {
do {
if FileManager.default.fileExists(atPath: tempUrl.path) {
strive FileManager.default.removeItem(at: tempUrl)
}
strive FileManager.default.createDirectory(at: tempUrl, withIntermediateDirectories: true)
}
catch { print(error)
}
}
static func makeTempImage(symbolName: String, colour: UIColor) -> String {
var config = UIImage.SymbolConfiguration(paletteColors: [color])
config = config.making use of(UIImage.SymbolConfiguration(font: .systemFont(ofSize: 96.0)))
let picture = UIImage(systemName: symbolName, withConfiguration: config)
if let pngData = picture?.pngData(){
let fileName = "(symbolName.cut up(separator: ".")[0]).png"
let filePath = tempUrl.appendingPathComponent(fileName)
strive? pngData.write(to: filePath)
return filePath.absoluteString
} else {
return ""
}
}
}
extension UIColor {
static func random(from colours: [UIColor]) -> UIColor? {
return colours.randomElement()
}
}
// MARK: - That is just for preview in Xcode
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}