iosswiftswiftuiswiftui-swipeactions

Showing image and text in swipe actions in SwiftUI


I am running my app on iOS 17 and have added swipe actions on list row. I wish to show the image of the action and text below it, but I noticed that only image gets displayed.

Code:

import SwiftUI
import Foundation

struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack {
                List {
                    Section {
                        HStack {
                            Text("Hello World").font(.body)
                            Spacer()
                           
                            Divider().frame(width: 3.0)
                                .overlay(Color.blue).padding(.trailing, -50.0).padding([.top, .bottom], -1.5)
                        }
                        .padding([.top, .bottom], 1.5)
                        .swipeActions(allowsFullSwipe: false) {
                            Button(role: .destructive) {
                                print("Deleting row")
                            } label: {
                                /// Note: Want to show Image and text below it.
                                VStack(spacing: 2.0) {
                                    Image(systemName: "trash")
                                    Text("Delete").font(.caption2)
                                }
                                
                                /// Note: Using label as well doesn't show both text and icon. It only shows the icon.
                                // Label("Delete", systemImage: "trash")
                            }
                            
                            // TODO: Add more swipe action buttons
                        }
                    }
                }
                .listStyle(.insetGrouped)
            }
        }
    }
}

How do I show image and text for swipe action?

enter image description here


Solution

  • One way to force an image to be shown together with a label is to build an image yourself that combines the two. This can be done by creating an Image with drawing instructions, see init(size:label:opaque:colorMode:renderer:)

    For example:

    private var deleteIcon: Image {
        Image(
            size: CGSize(width: 60, height: 40),
            label: Text("Delete")
        ) { ctx in
            ctx.draw(
                Image(systemName: "trash"),
                at: CGPoint(x: 30, y: 0),
                anchor: .top
            )
            ctx.draw(
                Text("Delete"),
                at: CGPoint(x: 30, y: 20),
                anchor: .top
            )
        }
    }
    

    You can then use this as the label for the swipe action:

    Button(role: .destructive) {
        print("Deleting row")
    } label: {
        deleteIcon
            .foregroundStyle(.white)
    }
    

    Screenshot


    EDIT Following from your comment, longer labels could be addressed in various ways:

    deleteIcon
        .foregroundStyle(.white)
        .font(.caption)
    

    However, this also impacts the size of the symbol. So it works better to set the font when rendering the label:

    ctx.draw(
        Text("Check in").font(.caption),
        // ...
    
    ctx.draw(
        Text("A longer label"),
        in: CGRect(x: 0, y: 20, width: 60, height: 20)
    )
    
    advancedSettingsIcon
        .foregroundStyle(.white)
        .multilineTextAlignment(.center)
    

    A more generic solution

    Ideally, the generation of this type of composite icon should be able to handle longer labels automatically. This is possible by resolving the text and the image inside the function and then examining their sizes.

    So here is a more general-purpose solution for generating a swipe icon. It includes the following logic:

    I found that if the result has square proportions, it is able to fill the full height of the list row. So the function below generates an image of size 60x60. This gets scaled-to-fit, which probably means it gets shrunk a bit when actually used:

    private func swipeIcon(label: String, symbolName: String) -> some View {
        let w: CGFloat = 60
        let h = w
        let size = CGSize(width: w, height: h)
        let text = Text(LocalizedStringKey(label))
        let symbol = Image(systemName: symbolName)
        return Image(size: size, label: text) { ctx in
            let resolvedText = ctx.resolve(text)
            let textSize = resolvedText.measure(in: CGSize(width: w, height: h * 0.6))
            let resolvedSymbol = ctx.resolve(symbol)
            let symbolSize = resolvedSymbol.size
            let heightForSymbol: CGFloat = min(h * 0.35, (h * 0.9) - textSize.height)
            let widthForSymbol = (heightForSymbol / symbolSize.height) * symbolSize.width
            let xSymbol = (w - widthForSymbol) / 2
            let ySymbol = max(h * 0.05, heightForSymbol - (textSize.height * 0.6))
            let yText = ySymbol + heightForSymbol + max(0, ((h * 0.8) - heightForSymbol - textSize.height) / 2)
            let xText = (w - textSize.width) / 2
            ctx.draw(
                resolvedSymbol,
                in: CGRect(x: xSymbol, y: ySymbol, width: widthForSymbol, height: heightForSymbol)
            )
            ctx.draw(
                resolvedText,
                in: CGRect(x: xText, y: yText, width: textSize.width, height: textSize.height)
            )
        }
        .foregroundStyle(.white)
        .font(.body)
        .lineLimit(2)
        .lineSpacing(-2)
        .minimumScaleFactor(0.7)
        .multilineTextAlignment(.center)
    }
    

    Here's how it can be used:

    Button(role: .destructive) {
        print("Deleting row")
    } label: {
        swipeIcon(label: "Delete", symbolName: "trash")
    }
    
    Button(role: .none) {
        print("Advanced settings")
    } label: {
        swipeIcon(label: "Advanced settings", symbolName: "gearshape")
    }
    .tint(.orange)
    

    Screenshot