scrolling wip with perf problems

This commit is contained in:
2025-07-20 22:18:05 +02:00
parent abbb855c77
commit 2f6f29603a
2 changed files with 299 additions and 78 deletions

View File

@@ -1,7 +1,7 @@
//TODO: Implement support for bonus chapters
//TODO: Implement scrolling support
//TODO: Implement Anilist support?
//TODO: Evict the oldest image from cache
//TODO: Properly avoid swallowing of input from UICollectionView used for scrolling
//TODO: Fix UICollectionView sizeForItemAt method performance either by caching or lazy loading
import UIKit
@@ -12,6 +12,12 @@ enum PageTurn {
case previous
}
enum ReadProgress: Codable {
case leftToRight(chapter: Int, page: Int)
case rightToLeft(chapter: Int, page: Int)
case scroll(CGPoint)
}
enum PageTurnMode: Codable {
case leftToRight
case rightToLeft
@@ -29,9 +35,7 @@ struct GlobalState: Codable {
}
struct LocalState: Codable {
var chapter: Int
var page: Int
var turnMode: PageTurnMode = .leftToRight
var progress: ReadProgress
var backgroundColor: String = "black"
}
@@ -53,7 +57,7 @@ struct Metadata: Decodable {
struct ChapterMetadata: Decodable {
var chapter: Float
var volume: Float
var title: String
var title: String?
var pages: Int
}
@@ -61,6 +65,11 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
var homeView = UIView()
var readerView = UIView()
@IBOutlet var scrollingCollectionView: UICollectionView!
var scrollPos: CGPoint!
var hasSetContentOffset = false
var pageCount = 0
var imageView = UIImageView()
var mode = PageTurnMode.leftToRight
var metadata: Metadata!
@@ -71,6 +80,9 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
var leftTap: UITapGestureRecognizer!
var rightTap: UITapGestureRecognizer!
var topTap: UITapGestureRecognizer!
let leftView = UIView()
let rightView = UIView()
let topView = UIView()
let topBarView = UIView()
let bottomBarView = UIView()
@@ -90,7 +102,7 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
let documentsURL = getDocumentsURL().unsafelyUnwrapped
var comics: [Comic] = []
var collectionView: UICollectionView!
@IBOutlet var comicCollectionView: UICollectionView!
let imageLoader = ImageLoader.init()
@@ -133,6 +145,7 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
])
loadComics()
setupImageView()
setupScrollingCollectionView()
setupGestures()
setupBar()
setupHomeView()
@@ -147,13 +160,14 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
layout.minimumInteritemSpacing = spacing
layout.minimumLineSpacing = spacing
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.dataSource = self
collectionView.delegate = self
collectionView.backgroundColor = .white
collectionView.register(ImageCell.self, forCellWithReuseIdentifier: "ImageCell")
homeView.addSubview(collectionView)
comicCollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
comicCollectionView.translatesAutoresizingMaskIntoConstraints = false
comicCollectionView.dataSource = self
comicCollectionView.delegate = self
comicCollectionView.backgroundColor = .white
comicCollectionView.register(
ComicImageCell.self, forCellWithReuseIdentifier: "ComicImageCell")
homeView.addSubview(comicCollectionView)
}
func loadComics() {
@@ -230,13 +244,20 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
func saveLocalState() {
let color = readerView.backgroundColor ?? .black
let progress: ReadProgress =
switch self.mode {
case .leftToRight:
ReadProgress.leftToRight(chapter: self.currentChapter, page: self.currentPage)
case .rightToLeft:
ReadProgress.leftToRight(chapter: self.currentChapter, page: self.currentPage)
case .scroll: ReadProgress.scroll(scrollingCollectionView.contentOffset)
}
localStateQueue.async {
do {
try JSONEncoder().encode(
LocalState(
chapter: self.currentChapter, page: self.currentPage, turnMode: self.mode,
backgroundColor: self.convertColorToString(
color: color))
progress: progress, backgroundColor: self.convertColorToString(color: color)
)
).write(
to: self.currentPath.appendingPathComponent("state.json"))
} catch {
@@ -252,9 +273,21 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
contentsOfFile: currentPath.appendingPathComponent("state.json").path
).utf8)
let local = try JSONDecoder().decode(LocalState.self, from: json)
currentPage = local.page
currentChapter = local.chapter
mode = local.turnMode
switch local.progress {
case .leftToRight(let chapter, let page):
self.currentChapter = chapter
self.currentPage = page
self.mode = .leftToRight
case .rightToLeft(let chapter, let page):
self.currentChapter = chapter
self.currentPage = page
self.mode = .rightToLeft
case .scroll(let point):
if self.scrollPos == nil {
self.scrollPos = point
}
self.mode = .scroll
}
readerView.backgroundColor = convertStringToColor(str: local.backgroundColor)
} catch {
print("failed to load local state")
@@ -273,6 +306,36 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
setupTapZones()
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollingCollectionView.isHidden { return }
let centerPoint = CGPoint(
x: scrollingCollectionView.bounds.midX + scrollingCollectionView.contentOffset.x,
y: scrollingCollectionView.bounds.midY + scrollingCollectionView.contentOffset.y)
if let indexPath = scrollingCollectionView.indexPathForItem(at: centerPoint) {
var index = 0
var (chapter, page) = (1, 1)
while index < indexPath.item {
(chapter, page) = getChapterAndPageFromTurn(
chapter: chapter, page: page, turn: .next)
index += 1
}
currentChapter = chapter
currentPage = page
}
if scrollingCollectionView.isHidden == false {
saveLocalState()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if scrollingCollectionView.isHidden == false && mode == .scroll && !hasSetContentOffset {
scrollingCollectionView.setContentOffset(scrollPos, animated: false)
hasSetContentOffset = true
}
}
func readComic(name: String) {
readerView.isHidden = false
homeView.isHidden = true
@@ -280,18 +343,50 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
if let path = getPathFromComicName(name: name) {
currentPath = path
metadata = getMetadata(path: path)!
pageCount = metadata.chapters.map { $0.pages }.reduce(0, +)
globalState.comicName = metadata.title
saveGlobalState()
loadLocalState()
if mode != .scroll {
scrollingCollectionView.isHidden = true
leftView.isHidden = false
rightView.isHidden = false
setImages(path: path, metadata: metadata)
} else {
leftView.isHidden = true
rightView.isHidden = true
scrollingCollectionView.isHidden = false
scrollingCollectionView.reloadData()
}
}
}
func setupScrollingCollectionView() {
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 0
layout.minimumLineSpacing = 0
scrollingCollectionView = UICollectionView(
frame: view.bounds, collectionViewLayout: layout)
scrollingCollectionView.translatesAutoresizingMaskIntoConstraints = false
scrollingCollectionView.isUserInteractionEnabled = true
scrollingCollectionView.dataSource = self
scrollingCollectionView.delegate = self
scrollingCollectionView.backgroundColor = .white
scrollingCollectionView.isHidden = true
scrollingCollectionView.register(
ScrollingImageCell.self, forCellWithReuseIdentifier: "ScrollingImageCell")
readerView.addSubview(scrollingCollectionView)
NSLayoutConstraint.activate([
scrollingCollectionView.topAnchor.constraint(equalTo: readerView.topAnchor),
scrollingCollectionView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
scrollingCollectionView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
scrollingCollectionView.heightAnchor.constraint(equalTo: readerView.heightAnchor),
])
}
func setupTapZones() {
let leftView = UIView()
let rightView = UIView()
let topView = UIView()
leftView.translatesAutoresizingMaskIntoConstraints = false
rightView.translatesAutoresizingMaskIntoConstraints = false
topView.translatesAutoresizingMaskIntoConstraints = false
@@ -370,6 +465,10 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
info.textColor = .white
bottomBarView.addSubview(info)
NSLayoutConstraint.activate([
info.topAnchor.constraint(equalTo: bottomBarView.topAnchor),
info.bottomAnchor.constraint(equalTo: bottomBarView.bottomAnchor),
info.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor),
info.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor),
info.centerXAnchor.constraint(equalTo: bottomBarView.centerXAnchor),
info.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor),
])
@@ -543,10 +642,17 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
switch title.lowercased() {
case "left to right": mode = .leftToRight
case "right to left": mode = .rightToLeft
case "scroll": mode = .scroll
case "scroll":
if mode != .scroll {
scrollingCollectionView.reloadData()
}
mode = .scroll
default: break
}
}
scrollingCollectionView.isHidden = mode != .scroll
leftView.isHidden = mode == .scroll
rightView.isHidden = mode == .scroll
togglePageTurnDropdown()
saveLocalState()
}
@@ -574,17 +680,24 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
backgroundColorDropdownView.isHidden.toggle()
}
// func gestureRecognizer(
// _ gestureRecognizer: UIGestureRecognizer,
// shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
// ) -> Bool {
// if gestureRecognizer == leftTap || gestureRecognizer == rightTap,
// otherGestureRecognizer == topTap
// {
// return true
// }
// return false
// }
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
if gestureRecognizer == leftTap || gestureRecognizer == rightTap,
otherGestureRecognizer == topTap
{
return true
}
return false
}
func toggleBar() {
topBarView.isHidden.toggle()
@@ -596,6 +709,20 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if scrollingCollectionView.isHidden { return }
updateInfo()
saveLocalState()
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollingCollectionView.isHidden { return }
if !decelerate {
updateInfo()
}
saveLocalState()
}
func hideBar() {
topBarView.isHidden = true
bottomBarView.isHidden = true
@@ -690,14 +817,26 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
}
func updateInfo() {
info.text = """
\(metadata.title)
\(metadata.chapters[currentChapter - 1].title)
if metadata == nil { return }
if currentChapter == nil || currentPage == nil { return }
var text = "\(metadata.title)\n"
if let chapterTitle = metadata.chapters[currentChapter - 1].title {
text =
text + "\(chapterTitle)"
}
text =
text + """
Volume \(Int(metadata.chapters[currentChapter - 1].volume)) of \(Int(metadata.last_volume))
Chapter \(currentChapter!) of \(Int(metadata.last_chapter))
Page \(currentPage!) of \(metadata.chapters[currentChapter - 1].pages)
Image size: \(Int(imageView.image!.size.width))x\(Int(imageView.image!.size.height))
"""
if let image = imageView.image {
text =
text + "\nImage size: \(Int(image.size.width))x\(Int(image.size.height))"
}
info.text = text
}
func getImagePath(chapter: Int, volume: Int, page: Int, path: URL) -> URL! {
@@ -706,21 +845,6 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
if fileManager.fileExists(atPath: modernPath.path) {
return modernPath
}
//TODO: Remove this
let newPath = path.appendingPathComponent(String(format: "volume_%04d", volume))
.appendingPathComponent(
String(format: "chapter_%04d_image_%04d.png", chapter, page - 1))
if fileManager.fileExists(atPath: newPath.path) {
return newPath
}
let alternatePath = path.appendingPathComponent(String(format: "volume_%04d", volume))
.appendingPathComponent(
String(format: "chapter%03d_image_%03d.png", chapter, page - 1))
if fileManager.fileExists(atPath: alternatePath.path) {
return alternatePath
}
//
print("Did not find image at path")
return nil
}
@@ -822,12 +946,10 @@ class ViewController: UIViewController, UIGestureRecognizerDelegate {
currentPage = Int(path_meta[3].components(separatedBy: ".")[0])
if currentPage == nil {
print("unable to set currentPage")
assert(false)
}
} else {
// TODO: Remove this
assert(path_meta[2] == "image")
currentPage = Int(path_meta[3].components(separatedBy: ".")[0])! + 1
}
saveLocalState()
updateInfo()
} catch {
print("failed to set images")
@@ -872,40 +994,139 @@ func getDocumentsURL() -> URL? {
extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int)
func collectionView(
_ collectionView: UICollectionView, numberOfItemsInSection section: Int
)
-> Int
{
if collectionView == comicCollectionView {
return comics.count
} else {
return pageCount
}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath)
-> UICollectionViewCell
{
if collectionView == comicCollectionView {
let cell =
collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath)
as! ImageCell
collectionView.dequeueReusableCell(
withReuseIdentifier: "ComicImageCell", for: indexPath)
as! ComicImageCell
cell.imageView.image = comics[indexPath.item].cover
return cell
} else if collectionView == scrollingCollectionView {
let cell =
collectionView.dequeueReusableCell(
withReuseIdentifier: "ScrollingImageCell", for: indexPath)
as! ScrollingImageCell
if metadata == nil {
return cell
}
var index = 0
var (chapter, page) = (1, 1)
while index < indexPath.item {
(chapter, page) = getChapterAndPageFromTurn(
chapter: chapter, page: page, turn: .next)
index += 1
}
if let url = getImagePath(
chapter: chapter,
volume: Int(metadata.chapters[chapter - 1].volume),
page: page, path: currentPath)
{
// cell.imageView.image = UIImage(contentsOfFile: url.path)
imageLoader.loadImage(at: url) { image in cell.imageView.image = image }
}
return cell
} else {
assert(false)
}
// Xcode profiling sucks:
return
collectionView.dequeueReusableCell(
withReuseIdentifier: "ScrollingImageCell", for: indexPath)
as! ScrollingImageCell
}
func collectionView(
_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout,
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
if collectionView == comicCollectionView {
let spacing: CGFloat = 8
let itemsPerRow: CGFloat = 3
let totalSpacing = (itemsPerRow - 1) * spacing
let width = (collectionView.bounds.width - totalSpacing) / itemsPerRow
return CGSize(width: width, height: width)
} else if collectionView == scrollingCollectionView {
if metadata == nil {
return CGSize()
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
var index = 0
var (chapter, page) = (1, 1)
while index < indexPath.item {
(chapter, page) = getChapterAndPageFromTurn(
chapter: chapter, page: page, turn: .next)
index += 1
}
if let imagePath = getImagePath(
chapter: chapter,
volume: Int(metadata.chapters[chapter - 1].volume),
page: page, path: currentPath),
let imageSource = CGImageSourceCreateWithURL(imagePath as CFURL, nil),
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
as? [CFString: Any],
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat
{
return CGSize(width: readerView.bounds.width, height: height)
}
return CGSize(width: readerView.bounds.width, height: readerView.bounds.height)
} else {
assert(false)
}
// Xcode profiling sucks:
return CGSize()
}
func collectionView(
_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath
) {
if collectionView == comicCollectionView {
let selectedComic = comics[indexPath.item]
readComic(name: selectedComic.metadata.title)
}
}
}
class ImageCell: UICollectionViewCell {
class ScrollingImageCell: UICollectionViewCell {
let imageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class ComicImageCell: UICollectionViewCell {
let imageView = UIImageView()
override init(frame: CGRect) {

2
run.sh
View File

@@ -18,7 +18,7 @@ xcodebuild -scheme "$SCHEME" -configuration "$BUILD_TYPE" -derivedDataPath "$DER
APP_PATH="$DERIVED_DATA/Build/Products/Debug-iphonesimulator/$APP_NAME.app"
xcrun simctl install booted "$APP_PATH"
xcrun simctl spawn booted log stream --predicate 'process == "ImageViewer"' --style syslog &
# xcrun simctl spawn booted log stream --predicate 'process == "ImageViewer"' --style syslog &
xcrun simctl launch booted "$COMPANY.$APP_NAME"
sudo sed -i '' '/developerservices2\.apple\.com/d' /etc/hosts