Search code examples
swiftswiftuiuikitshadowuigraphicscontext

UIGraphicsBeginImageContext shadow output is not same as SwiftUI view shadow modifier


I want to fit an image over another image with shadow effect. At first, I build the swiftUI view as follows, then trying to get the same output using UIGraphicsBeginImageContext. But unfortunately, found issue here: the shadow blur in output image isn't same as the view. I am looking for getting the appropriate solution.

struct ContentView: View {
    @State private var imageRect : CGRect = .zero
    
    @State private var shadowBlur = 40.0
    @State private var shadowX = 100.0
    @State private var shadowY = 100.0
    
    var body: some View {
        VStack {
            GeometryReader(content: { geometry in
                ZStack {
                    Image("background")
                        .resizable()
                        .scaledToFit()
                        .overlay {
                            Image("foreground")
                                .resizable()
                                .scaledToFit()
                                .shadow(color: Color.red, radius: CGFloat(shadowBlur), x: shadowX, y: shadowY)
                        }
                        .clipped()
                        .background(GeometryGetter(rect: $imageRect))
                }
                .frame(width: geometry.size.width, height: geometry.size.height)
            })
            
            Button("Click") {
                let fgImage = UIImage(named: "foreground")!
                let bgImage = UIImage(named: "background")!
                
                let scale = bgImage.size.width / imageRect.width
                let fgFrame = fgImage.size.scaleSizeToFit(in: bgImage.size)
                let fggImage = fgImage.resize(targetSize: fgFrame)
                
                UIGraphicsBeginImageContext(bgImage.size)
                bgImage.draw(at: CGPoint.zero)
                let context = UIGraphicsGetCurrentContext()
                context?.setShadow(offset: CGSize(width: shadowX * scale, height: shadowY * scale), blur: shadowBlur * scale, color: UIColor.red.cgColor)
                let imageDrawPoint = CGPoint(x: bgImage.size.width/2.0 - fggImage.size.width / 2.0, y: bgImage.size.height/2.0 - fggImage.size.height / 2.0)
                fggImage.draw(at: imageDrawPoint)
                
                let outputImage = UIGraphicsGetImageFromCurrentImageContext()
                UIGraphicsEndImageContext()
                print("SUCCESS")
                
            }
        }
    }
}

extension UIImage {
    func resize(targetSize: CGSize) -> UIImage {
        let image = self.toCIImage()
        let scaleX = targetSize.width / image.extent.size.width
        let scaleY = targetSize.height / image.extent.size.height
        let scaleTransform = CGAffineTransform(scaleX: scaleX, y: scaleY)
        let scaledImage = image.transformed(by: scaleTransform)
        return scaledImage.toUIImage()
    }
}

extension CGSize {
    func scaleSizeToFit(in canvas: CGSize) -> CGSize {
        let widthScale = canvas.width / self.width
        let heightScale = canvas.height / self.height
        let scale = min(widthScale, heightScale)
        let scaledSize = CGSize(width: self.width * scale, height: self.height * scale)
        return scaledSize
    }
}

struct GeometryGetter: View {
    @Binding var rect: CGRect

    var body: some View {
        GeometryReader { (g) -> Path in
            print("width: \(g.size.width), height: \(g.size.height)")
            DispatchQueue.main.async {
                self.rect = g.frame(in: .global)
            }
            return Path()
        }
    }
}

Solution

  • Due to automatic image scaling, this might not give you exactly what you see on screen, but it will be much closer...

    You need to set the shadow blur value to match the screen scale factor, so change:

    context?.setShadow(offset: CGSize(width: shadowX * scale, height: shadowY * scale),
                       blur: shadowBlur * scale,
                       color: UIColor.red.cgColor)
    

    to:

    context?.setShadow(offset: CGSize(width: shadowX * scale, height: shadowY * scale),
                       blur: shadowBlur * scale * UIScreen.main.scale,
                       color: UIColor.red.cgColor)
    

    Another option would be to render the view itself (the ZStack) to a UIImage -- searching for How to convert a SwiftUI view to a UIImage comes up with lots of examples.