Skip to content

Commit a6ccd8f

Browse files
authored
Merge pull request #2406 from onevcat/feature/improve-on-failure-view
Add onFailureView modifier for custom failure views in SwiftUI
2 parents a9953d8 + d10eeaf commit a6ccd8f

File tree

7 files changed

+135
-4
lines changed

7 files changed

+135
-4
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: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ extension KFImage {
5252
private(set) var animating = false
5353

5454
var loadedImage: KFCrossPlatformImage? = nil { willSet { objectWillChange.send() } }
55+
var failureView: (() -> AnyView)? = nil { willSet { objectWillChange.send() } }
5556
var progress: Progress = .init()
5657

5758
func markLoading() {
@@ -69,9 +70,12 @@ extension KFImage {
6970
guard let source = context.source else {
7071
CallbackQueueMain.currentOrAsync {
7172
context.onFailureDelegate.call(KingfisherError.imageSettingError(reason: .emptySource))
72-
if let image = context.options.onFailureImage {
73+
if let view = context.failureView {
74+
self.failureView = view
75+
} else if let image = context.options.onFailureImage {
7376
self.loadedImage = image
7477
}
78+
7579
self.loading = false
7680
self.markLoaded(sendChangeEvent: false)
7781
}
@@ -126,7 +130,9 @@ extension KFImage {
126130
}
127131
case .failure(let error):
128132
CallbackQueueMain.currentOrAsync {
129-
if let image = context.options.onFailureImage {
133+
if let view = context.failureView {
134+
self.failureView = view
135+
} else if let image = context.options.onFailureImage {
130136
self.loadedImage = image
131137
}
132138
self.markLoaded(sendChangeEvent: false)

Sources/SwiftUI/ImageContext.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,13 @@ extension KFImage {
7878
get { propertyQueue.sync { _placeholder } }
7979
set { propertyQueue.sync { _placeholder = newValue } }
8080
}
81-
81+
82+
var _failureView: (() -> AnyView)? = nil
83+
var failureView: (() -> AnyView)? {
84+
get { propertyQueue.sync { _failureView } }
85+
set { propertyQueue.sync { _failureView = newValue } }
86+
}
87+
8288
var _startLoadingBeforeViewAppear: Bool = false
8389
var startLoadingBeforeViewAppear: Bool {
8490
get { propertyQueue.sync { _startLoadingBeforeViewAppear } }

Sources/SwiftUI/KFImageOptions.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,36 @@ extension KFImageProtocol {
123123
placeholder { _ in content() }
124124
}
125125

126+
/// Sets a failure `View` that is displayed when the image fails to load.
127+
///
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.
151+
public func onFailureView<F: View>(@ViewBuilder _ content: @escaping () -> F) -> Self {
152+
context.failureView = { AnyView(content()) }
153+
return self
154+
}
155+
126156
/// Enables canceling the download task associated with `self` when the view disappears.
127157
///
128158
/// - Parameter flag: A boolean value indicating whether to cancel the task.

Sources/SwiftUI/KFImageRenderer.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ struct KFImageRenderer<HoldingView> : View where HoldingView: KFImageHoldingView
4646
renderedImage().opacity(binder.loaded ? 1.0 : 0.0)
4747
if binder.loadedImage == nil {
4848
ZStack {
49-
if let placeholder = context.placeholder {
49+
// Priority: failureView > placeholder > Color.clear
50+
// failureView is only set when image loading fails
51+
if let failureView = binder.failureView {
52+
failureView()
53+
} else if let placeholder = context.placeholder {
5054
placeholder(binder.progress)
5155
} else {
5256
Color.clear

0 commit comments

Comments
 (0)