alex.bour
alex.bour

Reputation: 2976

How to tile a texture vertically only?

In my app, I need to display images (textures). Some are tiled, and some are stretched.

At the time, I have this code working:

Image(frame.image)
.resizable(resizingMode: (frame.repeatTexture == true) ? .tile : .stretch)
.frame(width: frame.width, height: frame.height)

However,the render of the .tile is not good. The texture is cropped in X axis. In the image sent, you can see that the bezel is not present at the right, it's truncated.

render

Original texture where there is a bezel at left and right:

texture

I would like to have the "repeat" only in Y for example. Seems there is no option for that by default.

Is there a tip, an option, or maybe another idea like using background() to force the texture to tile only in one axis?

Upvotes: 1

Views: 44

Answers (2)

Andrei G.
Andrei G.

Reputation: 1557

I know this was accepted, but I wanted to show a different method that may feel more accessible to some.

If .resizable() could be applied after setting a frame size for the image or scaling it to fit, this wouldn't be an issue. But, as far I know, .resizable() must be the first modifier or else you get a member error.

So the question then is, how can you set the width or aspect ratio of the image first, in such way that you end up with an Image view so you can use .resizable(resizingMode:) on it?

You can do so by resizing the input image first to fit the width of the frame, rendering as a (new) image and then using the resized image as the input for the Image view you will use .resizable(resizingMode:) on.

Now, there are a number of ways do it, as @Sweeper showed, but in the code below I used ImageRenderer, introduced with iOS 16, which can create images from SwiftUI views. That means you could build out your view however you want and then use that as the input to ImageRenderer, which will then basically become your repeated tile. See example at the end.

Here's the full code:

import SwiftUI

struct TileRepeatView: View {
    
    //Parameters
    let image: ImageResource
    let frameSize: CGSize
    
    //Body
    var body: some View {
        
        //ScrollView
        ScrollView(showsIndicators: false) {
            Rectangle()
                .stroke(.black)
                .background(alignment: .topLeading) {
                    
                    //Safely unwrap optional Image? returned by resizeToWidth()
                    if let resizedImage = resizeToWidth(image: image, targetSize: frameSize.width) {
                        
                        resizedImage // <- this is the returned Image view
                            .resizable(resizingMode: .tile) // <- now you can use .tile without clipping
                    } else {
                        //Show a placeholder or content unavailable message
                        ContentUnavailableView {
                            Label("Rendering failed", systemImage: "questionmark.circle.fill")
                        }
                    }
                }
                .frame(width: frameSize.width, height: frameSize.height)
        }
        .frame(maxWidth: .infinity, alignment: .center)
    }
    
    //Function to resize an image to a specified width and return an Image view
    private func resizeToWidth(image: ImageResource, targetSize: CGFloat) -> Image? {
        
        //Create a view required for ImageRenderer
        let imageView = Image(image)
            .resizable()
            .frame(width: targetSize)

        let renderer = ImageRenderer(content: imageView)
    
        //Return nil if renderer doesn't return an uiImage
        guard let uiImage = renderer.uiImage else {
            print("Failed to render image")
            return nil
        }
    
        //return Image view
        return Image(uiImage: uiImage)
    }
}

//Preview
#Preview {
    @Previewable @State var frameSize: CGSize = .init(width: 300, height: 700)
    
    //Preview controls
    HStack {
        Text("Frame:")
        Group {
            Button {
                frameSize = CGSize(width: 300, height: 700)
            } label: {
                Text("Size 1")
            }
            
            Button {
                frameSize = CGSize(width: 100, height: 800)
            } label: {
                Text("Size 2")
            }
            
            Button {
                frameSize = CGSize(width: 200, height: 1200)
            } label: {
                Text("Size 3")
            }
        }
        .buttonStyle(.bordered)
        .padding(.vertical)
    }
    
    TileRepeatView(image: .tile, frameSize: frameSize) // <- update the image here as needed
}

enter image description here

Note: I saved the original tile image you provided and roughly cropped it and then imported it to Assets with the name "tile". You can also provide a name in the format below or you can update the type to whatever suits your model:

TileRepeatView(image: .init(name: "tile", bundle: .main), frameSize: frameSize) 

This is the tile image used:

enter image description here

Optional example - Since ImageRenderer can take any views as input, you can customize the view as needed. Here's a rough example, using an overlay added to the imageView, that adds a watermark that will repeat with the tile.

//Create a view required for ImageRenderer
let imageView = Image(image)
    .resizable()
    .frame(width: targetSize)
    .overlay {
        Text("Sample")
            .font(.system(size: 100))
            .rotationEffect(.degrees(-90))
            .fixedSize()
            .foregroundStyle(.white.opacity(0.5))
    }

Upvotes: 0

Sweeper
Sweeper

Reputation: 273530

You can draw a stretched version of the image using a GraphicsContext, and then tile that using resizable,

let imageHeight = ... // replace this with the height of your image
let frame = CGSize(width: 300, height: 700) // as an example
Image(size: .init(width: frame.width, height: imageHeight)) { gc in
    let image = gc.resolve(Image(.tile))
    let scale = frame.width / image.size.width
    gc.scaleBy(x: scale, y: 1)
    gc.draw(image, at: .zero, anchor: .topLeading)
    gc.scaleBy(x: 1 / scale, y: 1)
}
.resizable(resizingMode: .tile)
.frame(width: frame.width, height: frame.height)

Or, just draw the entire tiled image with the GraphicsContext.

Image(size: frame) { gc in
    var startY: CGFloat = 0
    let image = gc.resolve(Image(.tile))
    while startY < frame.height {
        let scale = frame.width / image.size.width
        gc.scaleBy(x: scale, y: 1)
        gc.draw(image, at: .init(x: 0, y: startY), anchor: .topLeading)
        gc.scaleBy(x: 1 / scale, y: 1)
        startY += image.size.height
    }
}

Upvotes: 1

Related Questions