在 SwiftUI 中不可能实现 Curl 页面?

问题描述 投票:0回答:1

我正在探索如何在 SwiftUI 中实现页面卷曲,类似于 UIKit 中使用 UIPageViewController 和 .pageCurl 过渡样式提供的功能。

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

在 SwiftUI 中,我希望实现一种卷页效果,用户可以用手指翻页,类似于 Apple 书店中看到的风格。这是我一直在研究的一个简化的 SwiftUI 示例:

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)
    }
}

如果您对在 SwiftUI 中实现逼真的页面卷曲效果有任何提示或见解,我将非常感谢您的意见。谢谢!

ios swiftui uikit uipageviewcontroller tabview
1个回答
0
投票

我在 SwiftUI、iOS 和 macOS 中有一个页面卷曲解决方案。

iPad 上的卷页:

页面卷曲要求很高,但我设法将复杂性隐藏在一系列视图修饰符中。

I) 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 
        }

修饰符 pageCurlable 有 5 个基本参数:触发器、卷曲方向、卷曲持续时间、显示当前页面的 UIView 和显示下一页的刷新页面闭包。在我的上下文中,我需要文档参数,因为文档设置定义了视图的背景。

页面ID的.onChange修饰符决定翻页是向前还是向后,并通过切换触发器来启动翻页。

pageCurling 修饰符在内部使用另外 2 个修饰符。第一个计算静态页面卷曲图像,第二个计算该静态图像的动画。

  1. 计算静态页面卷曲图像: 它的基础是 CIImage 过滤器:CIFilter.pageCurlWithShadowTransition() 此 CIFilter 至少需要一个输入 CIImage。对于前向卷曲,该图像的来源是当前显示的 SwiftUI 视图。但对于向后卷曲,这是尚未显示的 SwiftUI 视图!

如何从 SwiftUI 视图获取 CIImage?根据我的理解,SwiftUI 视图只是如何获取可显示视图的方法。为了渲染,它们必须嵌入到 UIHostingView 中。当前显示的页面已嵌入 - 我传输前视图。对于未来视图(下一页),我使用特定的 PageCurlFutureView UIViewRepresentable。

这是 UIView 上获取 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
   }

在 macOS 上,这是 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
   }

对于前向翻页,图像源是当前显示的 SwiftUI 视图。

II)视图修改器生成静态页面卷曲图像:

    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()
   }

}

PageCurl修饰符有几个静态变量,它们携带CIImages的initialImage和futureImage。一旦它们被设置,相应的 UIImages 就会被计算出来,并被 SwiftUI 使用。

它有三个变量定义页面卷曲的状态: 进度,角度和卷曲前向定义使用哪个图像来计算页面卷曲,初始图像或未来图像。

它有一个核心功能,可以从输入图像生成卷曲图像:

func pageCurlImage(progress:Float, angle:Float) -> UIImage

它有一个静态函数将 UIView 的部分转换为 CIImage:

@MainActor static func image(rect:CGRect?, view:UIView?) -> CIImage? 

III) 卷页动画

使用新的视图函数

pageCurl(progress:Double, angle:Double, forward:Bool)
我可以将 SwiftUI 视图转换为其静态卷曲版本。我需要的是这些视图的动画系列。

我为此使用 SwiftUI .keyframeAnimator 修饰符。 KeyframeAnimators 以定义的方式逐步将参数从起始值更改为结束值。它们被触发了。

使用简单的结构来描述参数:

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

此动画视图修改器使用 .onAppear 修改器启动自身,并在动画结束后通过将 PageCurl 静态图像重置为 nil 并将参数 isPageCurling 设置为 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
            }
         }
   }
}

仍然是最后使用的PageCurlable修饰符。它是原始 SwiftUI 视图的 ZStack 和使用前一个修改器生成的 NSImage 的 SwiftUI Image 视图的相对复杂的组合。它使用几个状态参数来决定在什么情况下显示哪个ZStack。在此修改器中,如果需要,将计算来自偏移 SwiftUI 视图的initialImage 和 futureImage。

IV) PageCurlable 修饰符

    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))
   }
}

此修改器使用 PageCurlFutureView 结构体在向后卷曲时抓取下一页的图片进行卷曲:

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) { }

}

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
   }

NSImage 上的emptyImage 扩展:

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
   }
}
© www.soinside.com 2019 - 2024. All rights reserved.