Antonio A. Chavez
Antonio A. Chavez

Reputation: 1026

It's impossible Curl Pages in the SwiftUI?

I'm exploring how to implement page curling in SwiftUI, similar to what's available in UIKit using UIPageViewController with the .pageCurl transition style.

let PagesCurl = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)

In SwiftUI, I'm looking to achieve a page curl effect where users can flip pages with their finger, likely to the style seen in Apple Book Stores. Here's a simplified SwiftUI example I've been working on:

struct Episode1View: View {
    @State private var currentPage = 0
    let contentCount = 11 // Number of content slices

    var body: some View {
        TabView(selection: $currentPage) {
            ForEach(1...contentCount, id: \.self) { index in
                BeforeThePage(imageName: "C1 Slice \(index)")
            }
        }
        .navigationBarTitle("", displayMode: .inline)
        .navigationBarHidden(true)
        .rotation3DEffect(
            .degrees(currentPage == 0 ? 0 : 180),
            axis: (x: 0, y: 1, z: 0),
            anchor: .trailing,
            perspective: 0.5
        )
        .animation(.default)
    }
}

struct BeforeThePage: View {
    let imageName: String

    var body: some View {
        Image(imageName)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(Color.white)
    }
}

If you have any tips or insights on achieving a realistic page curling effect in SwiftUI, I'd greatly appreciate your input. Thank you!

Upvotes: 0

Views: 692

Answers (1)

M Wilm
M Wilm

Reputation: 294

I have a page curl solution in SwiftUI, iOS and macOS.

Page curling on iPad:

Page Curl on iPad

Page curling is demanding but I managed to hide the complexity in a series of view modifiers.

I) Code used to display a curlable page in iOS:

    struct ContentPage: View {
    
              //Page Curling
       @State var initiatePageCurl: Bool = false
       @State var pageCurlForward: Bool = true
       let totalDuration : Double = 1  // in seconds
       
       var refreshPage : @MainActor () -> Void {
          get {
             return { @MainActor in
                self.displayedObjectID = self.pageController.displayedObjectID
             }
          }
       }

 @State var displayedObjectID: DisplayableID = EmptyDisplayable().id
    
 var body: some View {
    
        let view = self.pageViewGenerator(displayedObjectID: self.displayedObjectID)
           let windowContentView = AppDelegate.shared?.topViewController(documentID: self.document.interfaceID)?.view
           
           let theView =
           view
              .pageCurlable(initiatePageCurl: self.$initiatePageCurl, curlForward: self.pageCurlForward,duration: self.totalDuration, view:windowContentView ,document:self.document, refreshPage: self.refreshPage)
              .onChange(of: self.pageController.displayedObjectID, { oldValue, newValue in
                 if self.usePageCurl {
                    let oldPageNum = self.pageController.object(for: oldValue)?.pageNumber ?? 0
                    let newPageNum =  self.pageController.object(for: newValue)?.pageNumber ?? 0
                    if oldPageNum <= newPageNum { self.pageCurlForward = true }
                    else { self.pageCurlForward = false }
                    self.initiatePageCurl.toggle()
                 }
                 else {
                    self.refreshPage()
                 }
              })
        return theView 
        }

The modifier pageCurlable takes 5 essential parameters, a trigger, the curling direction, the duration of the curl, the UIView that displays the current page and a refreshPage closure that displays the next page. In my context I need the document parameter because the document settings define which background the views have.

The .onChange modifier of the page ID decides whether the page curls is forward or backward and initiates the page curl by toggling the trigger.

The pageCurling modifier uses internally 2 other modifiers. One calculates a static page curl image and the second animates this static image.

  1. Calculating a static page curl image: The base of this is a CIImage filter: CIFilter.pageCurlWithShadowTransition() This CIFilter requires as a minimum an input CIImage. For a forward curl the source of this image is the currently displayed SwiftUI view. But for a backwards curl this is the not yet displayed SwiftUI view!

How to get a CIImage from a SwiftUI view? In my understanding SwiftUI views are merely recipes how to get a view that can be displayed. For rendering they must be embedded in an UIHostingView. The currently displayed page is already embedded - I transmit the front view. For the future view (the next page) I use a specific PageCurlFutureView UIViewRepresentable.

Here is the extension on UIView to get a CIImage:

func ciimage(rect viewRect: CGRect? = nil, pixelsPerPoint:CGFloat = 1) -> CIImage? {
      let oldBounds = self.bounds
      let rect = viewRect ?? oldBounds
      self.bounds = CGRect(origin: rect.origin, size: rect.size )
      
      let renderer = UIGraphicsImageRenderer(bounds:rect, format: UIGraphicsImageRenderer.standardImageFormat(scale:pixelsPerPoint))
      let uiImage = renderer.image(actions: {(context) in
                                   self.drawHierarchy(in: self.bounds, afterScreenUpdates: false)
       })
      self.bounds = oldBounds
      
      if let cgImage = uiImage.cgImage {
         let image = CIImage(cgImage: cgImage)
         let imageExtent = image.extent
         let scaleFactor = rect.width/imageExtent.width
         let scaledImage : CIImage
         if abs(scaleFactor - 1) > 0.1 {
            let scaleTransform = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
            scaledImage = image.transformed(by: scaleTransform)
         }
         else { scaledImage = image }
         return scaledImage
      }
      return nil
   }

On macOS this is an extension on NSView:

func ciimage(rect viewRect: CGRect?) -> CIImage? {
      let rect = viewRect ?? self.bounds
      if let bitMap = self.bitmapImageRepForCachingDisplay(in: rect) {
         self.cacheDisplay(in: rect, to: bitMap)
         
         let ciImage = CIImage(bitmapImageRep: bitMap)
         return ciImage
      }
      return nil
   }

For the forward page curl the source of the image is the currently displayed SwiftUI view.

II) View modifier to generate a static page curl image:

    struct PageCurl: ViewModifier {
   static var initialImage: CIImage? = nil {
      didSet {
         if let ciImage = self.initialImage {
            let imageExtentSize = ciImage.extent.size
            let context = CIContext()
            if let cgImage = context.createCGImage(ciImage, from: CGRect(origin: .zero, size: imageExtentSize)) {
               //let imageSize = CGSize(width: imageExtentSize.width/2.0, height: imageExtentSize.height/2.0)
               let UIImage = UIImage(cgImage: cgImage)
               Self._initialUIImage = UIImage
            }
            /*
            let smallSize = CGAffineTransform(scaleX: 0.5, y: 0.5)
            let smallciImage = ciImage.transformed(by: smallSize)
            Self.initialImageSmall = smallciImage
             */
         }
         else {
            Self._initialUIImage = nil
            //Self.initialImageSmall = nil
         }
      }
   }
   
   //Used for page curl transformations
   //static var initialImageSmall: CIImage?
   
   static var _initialUIImage: UIImage? = nil
   //Used for static full size display on the screen
   static var initialUIImage: UIImage {
      get {
         if let image = Self._initialUIImage { return image }
         else { return UIImage.emptyImage(size: CGSize(width: 100, height:100))}
      }
   }
   
   static var futureImage: CIImage? = nil {
      didSet {
         if let ciImage = self.futureImage {
            let imageExtentSize = ciImage.extent.size
            let context = CIContext()
            if let cgImage = context.createCGImage(ciImage, from: CGRect(origin: .zero, size: imageExtentSize)) {
               //let imageSize = CGSize(width: imageExtentSize.width/2.0, height: imageExtentSize.height/2.0)
               let UIImage = UIImage(cgImage: cgImage)
               Self._futureUIImage = UIImage
               
               /*
               let smallSize = CGAffineTransform(scaleX: 0.5, y: 0.5)
               let smallciImage = ciImage.transformed(by: smallSize)
               Self.futureImageSmall = smallciImage
                */
            }
         }
         else {
            Self._futureUIImage = nil
            //Self.futureImageSmall = nil
         }
      }
   }
   
   //Used for page curl transformations
   //static var futureImageSmall: CIImage?
   
   static var _futureUIImage: UIImage? = nil
   //Used for static full size display on the screen
   static var futureUIImage: UIImage {
      get {
         if let image = Self._futureUIImage { return image }
         else { return UIImage.emptyImage(size: CGSize(width: 100, height:100))}
      }
   }
   
   let ciContext = CIContext()
   let backsideImage = CIImage(color: CIColor(red: 0.95, green: 0.95, blue: 0.95))
   
   @MainActor
   // This function generates the static CIImages which are the primary source for static display and small images for page curling
   static func image(rect:CGRect?, view:UIView?) -> CIImage?  {
      guard let baseView = view
      else { return nil }
      let ciimage = baseView.ciimage(rect: rect, pixelsPerPoint: 2)
      return ciimage
   }

   
   let progress: Double
   let angle: Double // 0 .. 1/4 * Double.pi
   let curlForward: Bool
   
   
   func body(content: Content) -> some View {
      let image = self.pageCurlImage(progress: Float(self.progress), angle:Float(self.angle))
      let theView = Group {
         if self.progress == 0 { content }
         else {
            Image(uiImage: image)
         }
      }
      return theView
   }
   
   
   @MainActor
   func pageCurlImage(progress:Float, angle:Float) -> UIImage {
      //debugPrint("pageCurlImage, progress:", progress)
      //guard let ciimage = self.curlForward ? Self.initialImageSmall : Self.futureImageSmall
      guard let ciimage = self.curlForward ? Self.initialImage : Self.futureImage
      else { return UIImage.emptyImage(size: CGSize(width: 300, height: 200))}
     
      let imageExtentSize = ciimage.extent.size
      let imageSize = CGSize(width: imageExtentSize.width/2.0, height: imageExtentSize.height/2.0)
      
      let aFilter = CIFilter.pageCurlWithShadowTransition()
      aFilter.inputImage = ciimage
      aFilter.backsideImage = self.backsideImage
      aFilter.angle = Float.pi * 3.0/4.0 + angle
      aFilter.radius = 1200
      aFilter.time = progress
      
      /*
       aFilter.shadowSize = 0.5 // default value
       aFilter.shadowAmount = 0.7 // default value
       */
      
      if let filteredImage = aFilter.outputImage {
         let context = self.ciContext
         if let cgImage = context.createCGImage(filteredImage, from: CGRect(origin: .zero, size: imageExtentSize)) {
            /*
            if let scaledImage = self.scaledImage(cgImage, scale: 1) {
               let uiImage = UIImage(cgImage: scaledImage)
               return uiImage
            }
             */
            let uiImage = UIImage(cgImage: cgImage)
            return uiImage
         }
      }
      let uiImage = UIImage.emptyImage(size: imageSize)
      return uiImage
   }
   
   func scaledImage(_ cgImage: CGImage, scale : CGFloat) -> CGImage? {
      if abs(scale - 1.0) < 0.1 { return cgImage }
       // Calculate the new size
       let newSize = CGSize(width: CGFloat(cgImage.width) * scale, height: CGFloat(cgImage.height) * scale)
       
       // Create a context with double the size
       guard let context = CGContext(data: nil,
                                     width: Int(newSize.width),
                                     height: Int(newSize.height),
                                     bitsPerComponent: cgImage.bitsPerComponent,
                                     bytesPerRow: 0,
                                     space: cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB(),
                                     bitmapInfo: cgImage.bitmapInfo.rawValue) else {
           return nil
       }
       
       // Draw the original image into the context at the doubled size
       context.draw(cgImage, in: CGRect(origin: .zero, size: newSize))
       
       // Retrieve the resulting image from the context
       return context.makeImage()
   }

}

The PageCurl modifier has several static variables which carry the CIImages initialImage and futureImage. As soon as they are set the corresponding UIImages are calculated which will be used by SwiftUI.

It has three variables which define the state of the page curling: progress, angle and curlForward to define which image to take for calculating the page curl, the initialImage or the futureImage.

It has a central function which generates a curled image from the input images: func pageCurlImage(progress:Float, angle:Float) -> UIImage

and it has a static function to convert portions of an UIView to a CIImage: @MainActor static func image(rect:CGRect?, view:UIView?) -> CIImage?

III) Page curl animation

With the new view function pageCurl(progress:Double, angle:Double, forward:Bool) I can convert a SwiftUI view into its statically curled version. What I need is an animated series of these views.

I use the SwiftUI .keyframeAnimator modifier for this. KeyframeAnimators change parameters from a starting value to an ending value stepwise in a defined way. They are triggered.

To describe the parameters a simple struct is used:

struct AnimatablePageCurlProperties {
   var progress: Double = 0
   var angle: Double = 0
}

This animation view modifier starts itself using the .onAppear modifier and performs cleanup after the end of the animation by resetting the PageCurl static images to nil and setting the parameter isPageCurling to false.

    //MARK: - Animation Page Curl Modifier
struct PageCurlAnimation: ViewModifier {
   @State var trigger: Bool = false
   let duration: Double // in seconds
   let pageCurlForward: Bool
   
   @Binding var isPageCurling: Bool // to signal that page curling finished
   
   func body(content: Content) -> some View {
      let initialCurlingState: AnimatablePageCurlProperties
      let finalCurlingState: AnimatablePageCurlProperties
      if self.pageCurlForward {
         initialCurlingState = AnimatablePageCurlProperties()
         finalCurlingState = AnimatablePageCurlProperties(progress: 2.2, angle:(Double.pi * 1.0/4.0))
      }
      else {
         initialCurlingState = AnimatablePageCurlProperties(progress: 2.2, angle:(Double.pi * 1.0/4.0))
         finalCurlingState = AnimatablePageCurlProperties()
      }
      
      return content
         .keyframeAnimator(initialValue: initialCurlingState, trigger:self.trigger) { content, value in
            content
               .pageCurl(progress: value.progress, angle: value.angle, forward:self.pageCurlForward)
         } keyframes: { _ in
            KeyframeTrack(\.progress) {
               LinearKeyframe(finalCurlingState.progress, duration: self.duration)
            }
            KeyframeTrack(\.angle) {
               if self.pageCurlForward {
                  LinearKeyframe(finalCurlingState.angle, duration: self.duration/2.0)
               }
               else {
                  LinearKeyframe(initialCurlingState.angle, duration: self.duration/2.0)
                  LinearKeyframe(finalCurlingState.angle, duration: self.duration/2.0)
               }
            }
         }
         .onAppear() {
            Task.detached() {@MainActor in
               self.trigger.toggle()
               try? await Task.sleep(for: .seconds(self.duration))
               //debugPrint("Setting initialImage to nil")
               PageCurl.initialImage = nil
               PageCurl.futureImage = nil
               self.isPageCurling = false
            }
         }
   }
}

Remains the finally used PageCurlable modifier. It is a relatively complex conglomerate of ZStacks of the original SwiftUI view and SwiftUI Image views using the UIImages produced from the former modifier. It uses several state parameters to decide which ZStack to display under which circumstances. In this modifier the initialImage and futureImage are calculated when required.

IV) PageCurlable Modifier

    struct PageCurlable : ViewModifier {
   @Binding var initiatePageCurl: Bool
   let curlForward: Bool
   let duration: Double
   let contentView: UIView?
   let document: MemorizableDocument
   let refreshPage: (() -> Void)

   
   var contentViewState: ContentViewState {
      get {
         return self.document.mainContentViewState ?? ContentViewState()
      }
   }
   
   @State var initialPageImaged: Bool = false
   @State var futurePageImaged: Bool = false
   @State var isPageCurling: Bool = false
   
   func body(content: Content) -> some View {
      //debugPrint("PageCurlable - isPageCurling", self.isPageCurling, "initialPageImaged:", self.initialPageImaged, "future page imaged:", self.futurePageImaged)
      
      return GeometryReader { geometry in
         VStack() {
            if self.isPageCurling {
               if self.curlForward {
                  if self.initialPageImaged {
                     ZStack() {
                        content
                        Image(uiImage: PageCurl.initialUIImage)
                           .pageCurlAnimation(duration: self.duration, forward: self.curlForward, isPageCurling: self.$isPageCurling)
                     }
                  }
                  else {
                     content
                  }
               }
               else {
                  if (!self.futurePageImaged) {
                     ZStack() {
                        PageCurlFutureView(content: content.documentBackground(document: self.document, contentViewState: self.contentViewState).environment(self.contentViewState),  viewSize: geometry.frame(in: .global).size, futureViewRendered: self.$futurePageImaged)
                       Image(uiImage: PageCurl.initialUIImage)
                     }
                  }
                  else {
                     ZStack() {
                        Image(uiImage: PageCurl.initialUIImage)
                        Image(uiImage: PageCurl.futureUIImage)
                           .pageCurlAnimation(duration: self.duration, forward: self.curlForward, isPageCurling:self.$isPageCurling)
                     }
                  }
               }
            }
            else {
               content
            }
         }
         .onChange(of: self.initiatePageCurl, {
            Task {@MainActor in
               self.pageCurlInitiation(geometry: geometry) }
         })
      }
   }
   
   @MainActor
   func pageCurlInitiation(geometry: GeometryProxy) {
      let initialImage = PageCurl.image(rect: geometry.frame(in: .global), view:self.contentView)
      PageCurl.initialImage = initialImage
      self.futurePageImaged = false
      self.isPageCurling = true
      self.initialPageImaged = true
      self.refreshPage()
   }
}


extension View {
   func pageCurlable(initiatePageCurl: Binding<Bool>, curlForward: Bool, duration: Double, view:UIView?, document:MemorizableDocument, refreshPage: @escaping () -> Void) -> some View {
      self.modifier(PageCurlable(initiatePageCurl: initiatePageCurl, curlForward: curlForward, duration: duration, contentView: view, document:document, refreshPage: refreshPage))
   }
}

This modifier use the PageCurlFutureView struct to grab a picture of the next page for curling when curling backwards:

struct PageCurlFutureView<Content: View>: UIViewRepresentable {
    let content: Content
   let viewSize: CGSize
   @Binding var futureViewRendered: Bool
   
   
    
    func makeUIView(context: Context) -> UIView {
       let hostingController = UIHostingController(rootView: content)
       if let uiView = hostingController.view {
          uiView.isHidden = false
          uiView.bounds = CGRect(origin: .zero, size: self.viewSize)
          
          Task {@MainActor in
             let futureImage = PageCurl.image(rect: nil, view:hostingController.view)
             PageCurl.futureImage = futureImage
             self.futureViewRendered = true
          }
       }
      
       return hostingController.view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) { }

}

The emptyImage extension on UIImage:

static func emptyImage(size:CGSize, filledWithColor color: UIColor = UIColor.clear, scale: CGFloat = 0.0, opaque: Bool = false) -> UIImage {
         let rect = CGRectMake(0, 0, size.width, size.height)
       
      let imageSize = {
         if size.height == 0 || size.width == 0 {
            return CGSize(width: 100, height: 100)
         }
         else { return size }
      }()
      
      let rendererFormat = UIGraphicsImageRenderer.standardImageFormat()
      rendererFormat.opaque = opaque
      rendererFormat.scale = scale
      let renderer = UIGraphicsImageRenderer(size: imageSize, format: rendererFormat)
      let image = renderer.image { (context) in
         color.set()
         UIRectFill(rect)
      }
      
      return image
   }

The emptyImage extension on NSImage:

extension NSImage {
   
   static func emptyImage(size:CGSize, filledWithColor color: NSColor = NSColor.clear) -> NSImage {
      let theImage = NSImage(size: size, flipped: false, drawingHandler: { imageRect in
         color.setFill()
         imageRect.fill()
         return true 
      })
         
      return theImage
   }
}

Upvotes: 2

Related Questions