Skip to content

Commit d10eeaf

Browse files
committed
Add onFailureView modifier for custom failure views in SwiftUI
- Introduce onFailureView modifier that allows custom SwiftUI views as failure placeholders - Add LoadingFailureDemo to demonstrate both onFailureView and onFailureImage usage - Ensure onFailureView takes precedence over onFailureImage when both are set - Update KFImageRenderer to properly handle failure view rendering priority
1 parent 1321e20 commit d10eeaf

File tree

6 files changed

+118
-13
lines changed

6 files changed

+118
-13
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//
2+
// LoadingFailureDemo.swift
3+
// Kingfisher
4+
//
5+
// Created by onevcat on 2025/06/29.
6+
//
7+
// Copyright (c) 2025 Wei Wang <onevcat@gmail.com>
8+
//
9+
// Permission is hereby granted, free of charge, to any person obtaining a copy
10+
// of this software and associated documentation files (the "Software"), to deal
11+
// in the Software without restriction, including without limitation the rights
12+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13+
// copies of the Software, and to permit persons to whom the Software is
14+
// furnished to do so, subject to the following conditions:
15+
//
16+
// The above copyright notice and this permission notice shall be included in
17+
// all copies or substantial portions of the Software.
18+
//
19+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
// THE SOFTWARE.
26+
27+
import SwiftUI
28+
import Kingfisher
29+
30+
@available(iOS 14.0, *)
31+
struct LoadingFailureDemo: View {
32+
33+
var url: URL {
34+
URL(string: "https://example.com")!
35+
}
36+
37+
var warningImage: UIImage {
38+
let config = UIImage.SymbolConfiguration(pointSize: 50)
39+
return UIImage(
40+
systemName: "wrongwaysign",
41+
withConfiguration: config
42+
)!
43+
}
44+
45+
var body: some View {
46+
VStack {
47+
KFImage(url)
48+
.onFailureImage(warningImage) // onFailureImage should not work
49+
.onFailureView {
50+
ZStack {
51+
RoundedRectangle(cornerRadius: 20)
52+
.fill(Color.red.opacity(0.5))
53+
Image(systemName: "exclamationmark.triangle.fill")
54+
.resizable()
55+
.frame(width: 50, height: 47)
56+
.foregroundColor(.yellow)
57+
}
58+
}
59+
.frame(width: 200, height: 200)
60+
Text("onFailureView")
61+
Spacer().frame(height: 20)
62+
63+
KFImage(url)
64+
.onFailureImage(warningImage)
65+
.frame(width: 200, height: 200)
66+
.background(
67+
RoundedRectangle(cornerRadius: 20)
68+
.fill(Color.red.opacity(0.5))
69+
)
70+
Text("onFailureImage")
71+
}
72+
}
73+
}
74+
75+
@available(iOS 14.0, *)
76+
struct LoadingFailureDemo_Previews: PreviewProvider {
77+
static var previews: some View {
78+
LoadingFailureDemo()
79+
}
80+
}

Demo/Demo/Kingfisher-Demo/SwiftUIViews/MainView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ struct MainView: View {
5353
NavigationLink(destination: GeometryReaderDemo()) { Text("Geometry Reader") }
5454
NavigationLink(destination: TransitionViewDemo()) { Text("Transition") }
5555
NavigationLink(destination: ProgressiveJPEGDemo()) { Text("Progressive JPEG") }
56+
NavigationLink(destination: LoadingFailureDemo()) { Text("Loading Failure") }
5657
}
5758

5859
Section(header: Text("Regression Cases")) {

Demo/Kingfisher-Demo.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
D12EB83E24DD902300329EE1 /* TextAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12EB83D24DD902300329EE1 /* TextAttachmentViewController.swift */; };
5656
D12EB84024DDB9E100329EE1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D12EB83F24DDB9E000329EE1 /* LaunchScreen.storyboard */; };
5757
D12F67682CB10AE000AB63AB /* LivePhotoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12F67672CB10AD900AB63AB /* LivePhotoViewController.swift */; };
58+
D14799D92E1129A900053537 /* LoadingFailureDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14799D82E1129A800053537 /* LoadingFailureDemo.swift */; };
5859
D1679A461C4E78B20020FD12 /* Kingfisher-watchOS-Demo Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D1679A451C4E78B20020FD12 /* Kingfisher-watchOS-Demo Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5960
D16AAF282D5247CF00E7F764 /* Issue2352View.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16AAF272D5247CA00E7F764 /* Issue2352View.swift */; };
6061
D16CC3D824E03FEA00F1A515 /* AVAssetImageGeneratorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16CC3D724E03FEA00F1A515 /* AVAssetImageGeneratorViewController.swift */; };
@@ -209,6 +210,7 @@
209210
D12EB83F24DDB9E000329EE1 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
210211
D12F67672CB10AD900AB63AB /* LivePhotoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePhotoViewController.swift; sourceTree = "<group>"; };
211212
D13F49C21BEDA53F00CE335D /* Kingfisher-tvOS-Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Kingfisher-tvOS-Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; };
213+
D14799D82E1129A800053537 /* LoadingFailureDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingFailureDemo.swift; sourceTree = "<group>"; };
212214
D16218A4238EAA67004A1C6C /* Kingfisher-Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Kingfisher-Demo.entitlements"; sourceTree = "<group>"; };
213215
D1679A391C4E78B20020FD12 /* Kingfisher-watchOS-Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Kingfisher-watchOS-Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; };
214216
D1679A451C4E78B20020FD12 /* Kingfisher-watchOS-Demo Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Kingfisher-watchOS-Demo Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -453,6 +455,7 @@
453455
children = (
454456
4BC0ED4829A6EE4F003E9CD1 /* Regression */,
455457
D1F78A622589F17200930759 /* MainView.swift */,
458+
D14799D82E1129A800053537 /* LoadingFailureDemo.swift */,
456459
D1F78A612589F17200930759 /* ListDemo.swift */,
457460
D198F42125EDC4B900C53E0D /* GridDemo.swift */,
458461
4B779C8426743C2800FF9C1E /* GeometryReaderDemo.swift */,
@@ -724,6 +727,7 @@
724727
072922432638639D0089E810 /* AnimatedImageDemo.swift in Sources */,
725728
4B6E1B6D28DB4E8C0023B54B /* Issue1998View.swift in Sources */,
726729
D1A1CCA721A18A3200263AD8 /* UIViewController+KingfisherOperation.swift in Sources */,
730+
D14799D92E1129A900053537 /* LoadingFailureDemo.swift in Sources */,
727731
D1E612E22D75F9AC00DACD51 /* ProgressiveJPEGDemo.swift in Sources */,
728732
4B92FE5625FF906B00473088 /* AutoSizingTableViewController.swift in Sources */,
729733
D1F78A642589F17200930759 /* ListDemo.swift in Sources */,

Sources/SwiftUI/ImageBinder.swift

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ extension KFImage {
7070
guard let source = context.source else {
7171
CallbackQueueMain.currentOrAsync {
7272
context.onFailureDelegate.call(KingfisherError.imageSettingError(reason: .emptySource))
73-
if let image = context.options.onFailureImage {
74-
self.loadedImage = image
75-
}
7673
if let view = context.failureView {
7774
self.failureView = view
75+
} else if let image = context.options.onFailureImage {
76+
self.loadedImage = image
7877
}
78+
7979
self.loading = false
8080
self.markLoaded(sendChangeEvent: false)
8181
}
@@ -130,11 +130,10 @@ extension KFImage {
130130
}
131131
case .failure(let error):
132132
CallbackQueueMain.currentOrAsync {
133-
if let image = context.options.onFailureImage {
134-
self.loadedImage = image
135-
}
136133
if let view = context.failureView {
137134
self.failureView = view
135+
} else if let image = context.options.onFailureImage {
136+
self.loadedImage = image
138137
}
139138
self.markLoaded(sendChangeEvent: false)
140139
}

Sources/SwiftUI/KFImageOptions.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,29 @@ extension KFImageProtocol {
125125

126126
/// Sets a failure `View` that is displayed when the image fails to load.
127127
///
128-
/// - Parameter content: A view that represents failure.
129-
/// - Returns: A Kingfisher-compatible image view that includes the provided `content` as its failure.
128+
/// Use this modifier to provide a custom view when image loading fails. This offers more flexibility than
129+
/// `onFailureImage` by allowing any SwiftUI view as the failure placeholder.
130+
///
131+
/// Example:
132+
/// ```swift
133+
/// KFImage(url)
134+
/// .onFailureView {
135+
/// VStack {
136+
/// Image(systemName: "exclamationmark.triangle")
137+
/// .foregroundColor(.red)
138+
/// Text("Failed to load image")
139+
/// .font(.caption)
140+
/// Button("Retry") {
141+
/// // Retry logic
142+
/// }
143+
/// }
144+
/// }
145+
/// ```
146+
///
147+
/// - Note: If both `onFailureImage` and `onFailureView` are set, `onFailureView` takes precedence.
148+
///
149+
/// - Parameter content: A view builder that creates the failure view.
150+
/// - Returns: A Kingfisher-compatible image view that displays the provided `content` when image loading fails.
130151
public func onFailureView<F: View>(@ViewBuilder _ content: @escaping () -> F) -> Self {
131152
context.failureView = { AnyView(content()) }
132153
return self

Sources/SwiftUI/KFImageRenderer.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@ struct KFImageRenderer<HoldingView> : View where HoldingView: KFImageHoldingView
4646
renderedImage().opacity(binder.loaded ? 1.0 : 0.0)
4747
if binder.loadedImage == nil {
4848
ZStack {
49+
// Priority: failureView > placeholder > Color.clear
50+
// failureView is only set when image loading fails
4951
if let failureView = binder.failureView {
5052
failureView()
53+
} else if let placeholder = context.placeholder {
54+
placeholder(binder.progress)
5155
} else {
52-
if let placeholder = context.placeholder {
53-
placeholder(binder.progress)
54-
} else {
55-
Color.clear
56-
}
56+
Color.clear
5757
}
5858
}
5959
.onAppear { [weak binder = self.binder] in

0 commit comments

Comments
 (0)