Search code examples
swiftuitvosswiftui-navigationlink

SwiftUI - Image in NavigationLink clipped when adding cornerRadius


I'm working on a SwiftUI app for tvOS. I have a NavigationLink with an image and a line of text. The item works as expected, when in focus it grows. However, if I change the corner radius (the default is too much), then even though the corner radius is smaller and the image and text grow, the image is clipped (the image grows but its bounds remain the same). How can I fix this?

NavigationStack {
        ScrollView {
            VStack(alignment: .leading, spacing: 20) {
                ForEach(sections) { section in
                    VStack(alignment: .leading, spacing: 20) {
                        // Section title
                        Text(section.title)
                            .font(.title)
                            .padding(.horizontal)
                        
                        // Items
                        ScrollView(.horizontal) {
                            LazyHStack(spacing: 50) {
                                ForEach(section.items) { item in
                                    NavigationLink {
                                        VideoDetailView(mediaItem: item)
                                    } label: {
                                        AsyncImage(url: item.imageURL) { phase in
                                            switch phase {
                                            case .empty:
                                                // This shows while the image is loading
                                                ProgressView()
                                                    .aspectRatio(700/500, contentMode: .fit)
                                                    .containerRelativeFrame(.horizontal, count: 6, spacing: 50)
                                            case .success(let image):
                                                // This shows the successfully loaded image
                                                image
                                                    .resizable()
                                                    .aspectRatio(700/500, contentMode: .fit)
                                                    .containerRelativeFrame(.horizontal, count: 6, spacing: 50)
                                            case .failure(_):
                                                // This shows if the image fails to load
                                                Image(systemName: "photo")
                                                    .resizable()
                                                    .aspectRatio(700/500, contentMode: .fit)
                                                    .containerRelativeFrame(.horizontal, count: 6, spacing: 50)
                                            @unknown default:
                                                // Any other
                                                EmptyView()
                                            }
                                        } .cornerRadius(5) // WHen I add this, the image gets clipped
                                        Text(item.title)
                                        //Text(item.subtitle)
                                    }
                                    .buttonStyle(.borderless)
                                }
                            }
                        }
                        .scrollClipDisabled()
                    }
                }
            }
        }
    }

Solution

  • When you apply .cornerRadius, it is actually applying a clip shape. In fact, the modifier .cornerRadius is deprecated, you should use .clipShape instead.

    In order to avoid the clip effect that you describe, the corner radius needs to be applied before the scaling effect that happens when the link gets focus. However, the scaling effect is something that is built into the button style .borderless, which makes it difficult to intercept. You also need to prevent the larger corner radius from being applied, otherwise you will see no difference. The corner radius is built into the .borderless button style too.

    A way to solve is to use a custom ButtonStyle, instead of .borderless.

    • The scaling effect will need to be built into the custom button style too. This requires knowing when a link has received focus.

    • A FocusState variable can be used to detect when a link has focus.

    • If you want to emulate the scaling effect that you get with .borderless button style, you will notice that it is only the image that gets scaled, not the text label. However, the label moves down, to stay approximately the same distance from the scaled image.

    • In order to be able to apply different styling effects to the image and to the text, the two elements can be wrapped as a Label. Then the custom ButtonStyle can use a custom LabelStyle to perform the styling.

    Here is a simplified example to show how it can work this way. I have replaced the AsyncImage with a static image, but I would expect that it should work the same when the Label is created using an AsyncImage instead.

    struct ItemLabelStyle: LabelStyle {
        let isSelected: Bool
    
        func makeBody(configuration: Configuration) -> some View {
            VStack {
                configuration.icon
                    .aspectRatio(700/500, contentMode: .fit)
                    .containerRelativeFrame(.horizontal, count: 6, spacing: 50)
                    .clipShape(.rect(cornerRadius: 5))
                    .scaleEffect(isSelected ? 1.25 : 1.0)
                configuration.title
                    .offset(y: isSelected ? 23 : 0)
            }
        }
    }
    
    struct ItemButtonStyle: ButtonStyle {
        let isSelected: Bool
    
        func makeBody(configuration: Configuration) -> some View {
            configuration.label
                .labelStyle(ItemLabelStyle(isSelected: isSelected))
        }
    }
    
    // ContentView
    
    @FocusState private var selectedItem: UUID? // or whatever type the ids have
    
    NavigationStack {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 50) {
                ForEach(section.items) { item in
                    NavigationLink {
                        VideoDetailView(mediaItem: item)
                    } label: {
                        Label {
                            Text(item.title)
                        } icon: {
                            Image("image2")
                                .resizable()
                        }
                    }
                    .focused($selectedItem, equals: item.id)
                    .buttonStyle(ItemButtonStyle(isSelected: selectedItem == item.id))
                    .animation(.easeInOut, value: selectedItem)
                }
            }
        }
        .scrollClipDisabled()
    }
    

    Animation


    Spotlight effect

    When button style .borderless is used, a kind of spotlight effect is also applied to the selected image. This would be another styling effect that the custom label style could add to the image, if you wanted it.

    One way to implement the effect is to apply a semi-transparent RadialGradient (consisting of .white going to .clear) as an overlay. Something like:

    // ItemLabelStyle
    
    configuration.icon
        .aspectRatio(700/500, contentMode: .fit)
        .containerRelativeFrame(.horizontal, count: 6, spacing: 50)
        .overlay {
            RadialGradient(
                colors: [.white.opacity(0.25), .clear],
                center: UnitPoint(x: 0.5, y: 0.1),
                startRadius: 50,
                endRadius: 120
            )
            .opacity(isSelected ? 1 : 0)
        }
        .clipShape(.rect(cornerRadius: 5))
        .scaleEffect(isSelected ? 1.25 : 1.0)
    

    Screenshot