From 96df0dac57c54f64d1a059468e280f7cd2fd9010 Mon Sep 17 00:00:00 2001 From: Aleh Dzenisiuk Date: Wed, 9 Sep 2020 14:21:24 +0200 Subject: [PATCH] Initial export from MMMTemple --- Examples/.gitignore | 3 + Examples/MMMSafeAreaGuide/Podfile | 8 + .../UnsafeArea.xcodeproj/project.pbxproj | 378 +++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../MMMSafeAreaGuide/UnsafeArea/AppDelegate.h | 10 + .../MMMSafeAreaGuide/UnsafeArea/AppDelegate.m | 27 + .../AppIcon.appiconset/Contents.json | 98 ++ .../UnsafeArea/Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../MMMSafeAreaGuide/UnsafeArea/Info.plist | 43 + .../UnsafeArea/ViewController.h | 9 + .../UnsafeArea/ViewController.m | 145 ++ Examples/MMMSafeAreaGuide/UnsafeArea/main.m | 16 + MMMCommonUI.podspec | 47 + README.md | 24 + Sources/MMMCommonUI/CommonUI.swift | 149 ++ Sources/MMMCommonUI/MMMLayoutUtils.swift | 40 + Sources/MMMCommonUI/MMMStylesheet.swift | 14 + Sources/MMMCommonUI/NonStoryboardables.swift | 91 ++ Sources/MMMCommonUIObjC/MMMAnimations.h | 200 +++ Sources/MMMCommonUIObjC/MMMAnimations.m | 664 ++++++++ .../MMMCommonUIObjC/MMMAutoLayoutScrollView.h | 53 + .../MMMCommonUIObjC/MMMAutoLayoutScrollView.m | 197 +++ Sources/MMMCommonUIObjC/MMMCollectionView.h | 27 + Sources/MMMCommonUIObjC/MMMCollectionView.m | 29 + Sources/MMMCommonUIObjC/MMMCommonUI.h | 360 +++++ Sources/MMMCommonUIObjC/MMMCommonUI.m | 586 +++++++ Sources/MMMCommonUIObjC/MMMImageView.h | 37 + Sources/MMMCommonUIObjC/MMMImageView.m | 134 ++ Sources/MMMCommonUIObjC/MMMKeyboard.h | 86 ++ Sources/MMMCommonUIObjC/MMMKeyboard.m | 105 ++ Sources/MMMCommonUIObjC/MMMLayout.h | 555 +++++++ Sources/MMMCommonUIObjC/MMMLayout.m | 1367 +++++++++++++++++ Sources/MMMCommonUIObjC/MMMLoadableImage.h | 85 + Sources/MMMCommonUIObjC/MMMLoadableImage.m | 296 ++++ Sources/MMMCommonUIObjC/MMMPhoto.h | 74 + Sources/MMMCommonUIObjC/MMMPhoto.m | 98 ++ .../MMMPhotoLibraryLoadableImage.h | 38 + .../MMMPhotoLibraryLoadableImage.m | 129 ++ .../MMMCommonUIObjC/MMMScrollViewShadows.h | 78 + .../MMMCommonUIObjC/MMMScrollViewShadows.m | 266 ++++ Sources/MMMCommonUIObjC/MMMShadowView.h | 68 + Sources/MMMCommonUIObjC/MMMShadowView.m | 206 +++ Sources/MMMCommonUIObjC/MMMStubView.h | 26 + Sources/MMMCommonUIObjC/MMMStubView.m | 53 + .../MMMCommonUIObjC/MMMStubViewController.h | 23 + .../MMMCommonUIObjC/MMMStubViewController.m | 121 ++ Sources/MMMCommonUIObjC/MMMStylesheet.h | 212 +++ Sources/MMMCommonUIObjC/MMMStylesheet.m | 316 ++++ Sources/MMMCommonUIObjC/MMMTableView.h | 30 + Sources/MMMCommonUIObjC/MMMTableView.m | 33 + Sources/MMMCommonUIObjC/MMMTableViewCell.h | 21 + Sources/MMMCommonUIObjC/MMMTableViewCell.m | 16 + .../MMMCommonUIObjC/MMMVerticalGradientView.h | 28 + .../MMMCommonUIObjC/MMMVerticalGradientView.m | 69 + Sources/MMMCommonUIObjC/MMMViewWrappingCell.h | 30 + Sources/MMMCommonUIObjC/MMMViewWrappingCell.m | 45 + Sources/MMMCommonUIObjC/MMMWebView.h | 24 + Sources/MMMCommonUIObjC/MMMWebView.m | 67 + Tests/MMMCommonUI.swift | 10 + 63 files changed, 8028 insertions(+) create mode 100644 Examples/.gitignore create mode 100644 Examples/MMMSafeAreaGuide/Podfile create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.pbxproj create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea/AppDelegate.h create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea/AppDelegate.m create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea/Assets.xcassets/Contents.json create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea/Base.lproj/LaunchScreen.storyboard create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea/Info.plist create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea/ViewController.h create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea/ViewController.m create mode 100644 Examples/MMMSafeAreaGuide/UnsafeArea/main.m create mode 100644 MMMCommonUI.podspec create mode 100644 README.md create mode 100644 Sources/MMMCommonUI/CommonUI.swift create mode 100644 Sources/MMMCommonUI/MMMLayoutUtils.swift create mode 100644 Sources/MMMCommonUI/MMMStylesheet.swift create mode 100644 Sources/MMMCommonUI/NonStoryboardables.swift create mode 100644 Sources/MMMCommonUIObjC/MMMAnimations.h create mode 100644 Sources/MMMCommonUIObjC/MMMAnimations.m create mode 100644 Sources/MMMCommonUIObjC/MMMAutoLayoutScrollView.h create mode 100644 Sources/MMMCommonUIObjC/MMMAutoLayoutScrollView.m create mode 100644 Sources/MMMCommonUIObjC/MMMCollectionView.h create mode 100644 Sources/MMMCommonUIObjC/MMMCollectionView.m create mode 100644 Sources/MMMCommonUIObjC/MMMCommonUI.h create mode 100644 Sources/MMMCommonUIObjC/MMMCommonUI.m create mode 100644 Sources/MMMCommonUIObjC/MMMImageView.h create mode 100644 Sources/MMMCommonUIObjC/MMMImageView.m create mode 100644 Sources/MMMCommonUIObjC/MMMKeyboard.h create mode 100644 Sources/MMMCommonUIObjC/MMMKeyboard.m create mode 100644 Sources/MMMCommonUIObjC/MMMLayout.h create mode 100644 Sources/MMMCommonUIObjC/MMMLayout.m create mode 100644 Sources/MMMCommonUIObjC/MMMLoadableImage.h create mode 100644 Sources/MMMCommonUIObjC/MMMLoadableImage.m create mode 100644 Sources/MMMCommonUIObjC/MMMPhoto.h create mode 100644 Sources/MMMCommonUIObjC/MMMPhoto.m create mode 100644 Sources/MMMCommonUIObjC/MMMPhotoLibraryLoadableImage.h create mode 100644 Sources/MMMCommonUIObjC/MMMPhotoLibraryLoadableImage.m create mode 100644 Sources/MMMCommonUIObjC/MMMScrollViewShadows.h create mode 100644 Sources/MMMCommonUIObjC/MMMScrollViewShadows.m create mode 100644 Sources/MMMCommonUIObjC/MMMShadowView.h create mode 100644 Sources/MMMCommonUIObjC/MMMShadowView.m create mode 100644 Sources/MMMCommonUIObjC/MMMStubView.h create mode 100644 Sources/MMMCommonUIObjC/MMMStubView.m create mode 100644 Sources/MMMCommonUIObjC/MMMStubViewController.h create mode 100644 Sources/MMMCommonUIObjC/MMMStubViewController.m create mode 100644 Sources/MMMCommonUIObjC/MMMStylesheet.h create mode 100644 Sources/MMMCommonUIObjC/MMMStylesheet.m create mode 100644 Sources/MMMCommonUIObjC/MMMTableView.h create mode 100644 Sources/MMMCommonUIObjC/MMMTableView.m create mode 100644 Sources/MMMCommonUIObjC/MMMTableViewCell.h create mode 100644 Sources/MMMCommonUIObjC/MMMTableViewCell.m create mode 100644 Sources/MMMCommonUIObjC/MMMVerticalGradientView.h create mode 100644 Sources/MMMCommonUIObjC/MMMVerticalGradientView.m create mode 100644 Sources/MMMCommonUIObjC/MMMViewWrappingCell.h create mode 100644 Sources/MMMCommonUIObjC/MMMViewWrappingCell.m create mode 100644 Sources/MMMCommonUIObjC/MMMWebView.h create mode 100644 Sources/MMMCommonUIObjC/MMMWebView.m create mode 100644 Tests/MMMCommonUI.swift diff --git a/Examples/.gitignore b/Examples/.gitignore new file mode 100644 index 0000000..5f4246f --- /dev/null +++ b/Examples/.gitignore @@ -0,0 +1,3 @@ +Pods/ +Podfile.lock +xcuserdata/ \ No newline at end of file diff --git a/Examples/MMMSafeAreaGuide/Podfile b/Examples/MMMSafeAreaGuide/Podfile new file mode 100644 index 0000000..35ee603 --- /dev/null +++ b/Examples/MMMSafeAreaGuide/Podfile @@ -0,0 +1,8 @@ +platform :ios, '11.0' + +source 'https://github.com/mediamonks/MMMSpecs.git' +source 'https://github.com/CocoaPods/Specs.git' + +target 'UnsafeArea' do + pod 'MMMCommonUI/ObjC', :path => '../..' +end diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.pbxproj b/Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8eefbdd --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.pbxproj @@ -0,0 +1,378 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + AFB53FE7A6BC61D4C0164197 /* libPods-UnsafeArea.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1553BDE897630000975D8D63 /* libPods-UnsafeArea.a */; }; + F2507A77240D5A3500F0146A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = F2507A76240D5A3500F0146A /* AppDelegate.m */; }; + F2507A7D240D5A3500F0146A /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = F2507A7C240D5A3500F0146A /* ViewController.m */; }; + F2507A82240D5A3600F0146A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F2507A81240D5A3600F0146A /* Assets.xcassets */; }; + F2507A85240D5A3600F0146A /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F2507A83240D5A3600F0146A /* LaunchScreen.storyboard */; }; + F2507A88240D5A3600F0146A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = F2507A87240D5A3600F0146A /* main.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1553BDE897630000975D8D63 /* libPods-UnsafeArea.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-UnsafeArea.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 2B9DB772FB7452ED712B9B81 /* Pods-UnsafeArea.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnsafeArea.debug.xcconfig"; path = "Target Support Files/Pods-UnsafeArea/Pods-UnsafeArea.debug.xcconfig"; sourceTree = ""; }; + CD620BD6B316F23572960E19 /* Pods-UnsafeArea.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnsafeArea.release.xcconfig"; path = "Target Support Files/Pods-UnsafeArea/Pods-UnsafeArea.release.xcconfig"; sourceTree = ""; }; + F2507A72240D5A3500F0146A /* UnsafeArea.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UnsafeArea.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F2507A75240D5A3500F0146A /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + F2507A76240D5A3500F0146A /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + F2507A7B240D5A3500F0146A /* ViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = ""; }; + F2507A7C240D5A3500F0146A /* ViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ViewController.m; sourceTree = ""; }; + F2507A81240D5A3600F0146A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F2507A84240D5A3600F0146A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + F2507A86240D5A3600F0146A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F2507A87240D5A3600F0146A /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F2507A6F240D5A3500F0146A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AFB53FE7A6BC61D4C0164197 /* libPods-UnsafeArea.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 3EB276A831AA7CC4E7F526BA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1553BDE897630000975D8D63 /* libPods-UnsafeArea.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 7FB777327C453F43816E7343 /* Pods */ = { + isa = PBXGroup; + children = ( + 2B9DB772FB7452ED712B9B81 /* Pods-UnsafeArea.debug.xcconfig */, + CD620BD6B316F23572960E19 /* Pods-UnsafeArea.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + F2507A69240D5A3500F0146A = { + isa = PBXGroup; + children = ( + F2507A74240D5A3500F0146A /* UnsafeArea */, + F2507A73240D5A3500F0146A /* Products */, + 7FB777327C453F43816E7343 /* Pods */, + 3EB276A831AA7CC4E7F526BA /* Frameworks */, + ); + sourceTree = ""; + }; + F2507A73240D5A3500F0146A /* Products */ = { + isa = PBXGroup; + children = ( + F2507A72240D5A3500F0146A /* UnsafeArea.app */, + ); + name = Products; + sourceTree = ""; + }; + F2507A74240D5A3500F0146A /* UnsafeArea */ = { + isa = PBXGroup; + children = ( + F2507A75240D5A3500F0146A /* AppDelegate.h */, + F2507A76240D5A3500F0146A /* AppDelegate.m */, + F2507A81240D5A3600F0146A /* Assets.xcassets */, + F2507A86240D5A3600F0146A /* Info.plist */, + F2507A83240D5A3600F0146A /* LaunchScreen.storyboard */, + F2507A87240D5A3600F0146A /* main.m */, + F2507A7B240D5A3500F0146A /* ViewController.h */, + F2507A7C240D5A3500F0146A /* ViewController.m */, + ); + path = UnsafeArea; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F2507A71240D5A3500F0146A /* UnsafeArea */ = { + isa = PBXNativeTarget; + buildConfigurationList = F2507A8B240D5A3600F0146A /* Build configuration list for PBXNativeTarget "UnsafeArea" */; + buildPhases = ( + 27602A78B2A828BE7A50B32F /* [CP] Check Pods Manifest.lock */, + F2507A6E240D5A3500F0146A /* Sources */, + F2507A6F240D5A3500F0146A /* Frameworks */, + F2507A70240D5A3500F0146A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = UnsafeArea; + productName = UnsafeArea; + productReference = F2507A72240D5A3500F0146A /* UnsafeArea.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F2507A6A240D5A3500F0146A /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1110; + ORGANIZATIONNAME = "MediaMonks B.V."; + TargetAttributes = { + F2507A71240D5A3500F0146A = { + CreatedOnToolsVersion = 11.1; + }; + }; + }; + buildConfigurationList = F2507A6D240D5A3500F0146A /* Build configuration list for PBXProject "UnsafeArea" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F2507A69240D5A3500F0146A; + productRefGroup = F2507A73240D5A3500F0146A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F2507A71240D5A3500F0146A /* UnsafeArea */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F2507A70240D5A3500F0146A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F2507A85240D5A3600F0146A /* LaunchScreen.storyboard in Resources */, + F2507A82240D5A3600F0146A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 27602A78B2A828BE7A50B32F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-UnsafeArea-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F2507A6E240D5A3500F0146A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F2507A7D240D5A3500F0146A /* ViewController.m in Sources */, + F2507A77240D5A3500F0146A /* AppDelegate.m in Sources */, + F2507A88240D5A3600F0146A /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + F2507A83240D5A3600F0146A /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + F2507A84240D5A3600F0146A /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + F2507A89240D5A3600F0146A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + F2507A8A240D5A3600F0146A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F2507A8C240D5A3600F0146A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2B9DB772FB7452ED712B9B81 /* Pods-UnsafeArea.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = UnsafeArea/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.mediamonks.UnsafeArea; + PRODUCT_NAME = "$(TARGET_NAME)"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F2507A8D240D5A3600F0146A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CD620BD6B316F23572960E19 /* Pods-UnsafeArea.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = UnsafeArea/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.mediamonks.UnsafeArea; + PRODUCT_NAME = "$(TARGET_NAME)"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F2507A6D240D5A3500F0146A /* Build configuration list for PBXProject "UnsafeArea" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F2507A89240D5A3600F0146A /* Debug */, + F2507A8A240D5A3600F0146A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F2507A8B240D5A3600F0146A /* Build configuration list for PBXNativeTarget "UnsafeArea" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F2507A8C240D5A3600F0146A /* Debug */, + F2507A8D240D5A3600F0146A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F2507A6A240D5A3500F0146A /* Project object */; +} diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..35cd60e --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea.xcworkspace/contents.xcworkspacedata b/Examples/MMMSafeAreaGuide/UnsafeArea.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..851d27a --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/MMMSafeAreaGuide/UnsafeArea.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea/AppDelegate.h b/Examples/MMMSafeAreaGuide/UnsafeArea/AppDelegate.h new file mode 100644 index 0000000..868d195 --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea/AppDelegate.h @@ -0,0 +1,10 @@ +// +// Demo of iOS layout loop bug involving `safeAreaLayoutGuide` and transforms. +// Copyright (C) 2020, MediaMonks. +// + +#import + +@interface AppDelegate : UIResponder +@end + diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea/AppDelegate.m b/Examples/MMMSafeAreaGuide/UnsafeArea/AppDelegate.m new file mode 100644 index 0000000..045942b --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea/AppDelegate.m @@ -0,0 +1,27 @@ +// +// Demo of iOS layout loop bug involving `safeAreaLayoutGuide` and transforms. +// Copyright (C) 2020, MediaMonks. +// + +#import "AppDelegate.h" +#import "ViewController.h" + +@interface AppDelegate () +@end + +@implementation AppDelegate { + UIWindow *_window; + ViewController *_viewController; +} + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + + _window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + _viewController = [[ViewController alloc] init]; + _window.rootViewController = _viewController; + [_window makeKeyAndVisible]; + + return YES; +} + +@end diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/MMMSafeAreaGuide/UnsafeArea/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea/Assets.xcassets/Contents.json b/Examples/MMMSafeAreaGuide/UnsafeArea/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea/Base.lproj/LaunchScreen.storyboard b/Examples/MMMSafeAreaGuide/UnsafeArea/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea/Info.plist b/Examples/MMMSafeAreaGuide/UnsafeArea/Info.plist new file mode 100644 index 0000000..5a63475 --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea/ViewController.h b/Examples/MMMSafeAreaGuide/UnsafeArea/ViewController.h new file mode 100644 index 0000000..0b6f89e --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea/ViewController.h @@ -0,0 +1,9 @@ +// +// Demo of iOS layout loop bug involving `safeAreaLayoutGuide` and transforms. +// Copyright (C) 2020, MediaMonks. +// + +#import + +@interface ViewController : UIViewController +@end diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea/ViewController.m b/Examples/MMMSafeAreaGuide/UnsafeArea/ViewController.m new file mode 100644 index 0000000..faa062f --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea/ViewController.m @@ -0,0 +1,145 @@ +// +// Demo of iOS layout loop bug involving `safeAreaLayoutGuide` and transforms. +// Copyright (C) 2020, MediaMonks. +// + +#import "ViewController.h" + +// Set to 1 to see the layout loop with safeAreaLayoutGuide. +#define DEMO_LAYOUT_LOOP 0 + +#if !DEMO_LAYOUT_LOOP +@import MMMCommonUI; +#endif + +@interface UnsafeAreaView: UIView +@end + +@implementation UnsafeAreaView { + UIView *_button; +} + +- (id)init { + + if (self = [super initWithFrame:CGRectZero]) { + + self.translatesAutoresizingMaskIntoConstraints = NO; + self.backgroundColor = [UIColor blueColor]; + + _button = [[UIView alloc] initWithFrame:CGRectZero]; + _button.translatesAutoresizingMaskIntoConstraints = NO; + _button.backgroundColor = [UIColor redColor]; + [self addSubview:_button]; + + NSDictionary *views = NSDictionaryOfVariableBindings(_button); + + // Exact numbers in the horizontal contraint are not important. + [NSLayoutConstraint activateConstraints:[NSLayoutConstraint + constraintsWithVisualFormat:@"H:|-(10)-[_button(==150)]-(10)-|" + options:0 metrics:nil views:views + ]]; + + [NSLayoutConstraint activateConstraints:[NSLayoutConstraint + constraintsWithVisualFormat:@"V:|-(10)-[_button(==150)]" + options:0 metrics:nil views:views + ]]; + #if DEMO_LAYOUT_LOOP + [NSLayoutConstraint activateConstraints:@[[NSLayoutConstraint + constraintWithItem:_button attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + // Use regular safeAreaLayoutGuide to see the bug. + toItem:self.safeAreaLayoutGuide attribute:NSLayoutAttributeBottom + multiplier:1 constant:-10 + ]]]; + #else + // This is to verify that mmm_constraintsWithVisualFormat uses mmm_safeAreaLayoutGuide. + [NSLayoutConstraint activateConstraints:[NSLayoutConstraint + mmm_constraintsWithVisualFormat:@"V:[_button]-<|" + options:0 metrics:nil views:views + ]]; + #endif + } + + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + // Follow in the console that it's called indefinitely with height and safeAreaInsets.bottom oscillating. + NSLog(@"height: %f, safeAreaInsets.bottom: %f", self.frame.size.height, self.safeAreaInsets.bottom); +} + +@end + +// +// +// +@interface ViewController () +@end + +@implementation ViewController { + UnsafeAreaView *_unsafeAreaView; + CGFloat _scale; + NSTimer *_timer; +} + +- (void)viewDidLoad { + + [super viewDidLoad]; + + self.view.backgroundColor = [UIColor whiteColor]; + + _unsafeAreaView = [[UnsafeAreaView alloc] init]; + [self.view addSubview:_unsafeAreaView]; + + NSDictionary *views = NSDictionaryOfVariableBindings(_unsafeAreaView); + + [NSLayoutConstraint activateConstraints:[NSLayoutConstraint + constraintsWithVisualFormat:@"H:|-(>=10)-[_unsafeAreaView]-(>=10)-|" + options:0 metrics:nil views:views + ]]; + [NSLayoutConstraint activateConstraints:@[[NSLayoutConstraint + constraintWithItem:_unsafeAreaView attribute:NSLayoutAttributeCenterX + relatedBy:NSLayoutRelationEqual + toItem:self.view attribute:NSLayoutAttributeCenterX + multiplier:1 constant:0 + ]]]; + + // Again, only vertical ones are important in this demo. + [NSLayoutConstraint activateConstraints:[NSLayoutConstraint + constraintsWithVisualFormat:@"V:[_unsafeAreaView]-0-|" + options:0 metrics:nil views:views + ]]; + + #if DEMO_LAYOUT_LOOP + + // Slightly delay applying the fatal transform, so the initial layout is visible. + [self performSelector:@selector(doUnsafeAreaInsets) withObject:nil afterDelay:1]; + + #else + + // This tries to apply different scales. + // Can be used to check our workaround or to find the scale leading to the loop. + + _scale = 1; + + _timer = [NSTimer scheduledTimerWithTimeInterval:0.01 repeats:YES block:^(NSTimer * _Nonnull timer) { + _scale = _scale * 0.999; + _unsafeAreaView.transform = CGAffineTransformMakeScale(1, _scale); + [self.view setNeedsLayout]; + NSLog(@"scale: %f", _scale); + }]; + + #endif +} + +- (void)doUnsafeAreaInsets { + + // Not every number causes the loop, e.g. 0.9 would be safe. + _unsafeAreaView.transform = CGAffineTransformMakeScale(1, 0.968491); + + // Don't have to mark it as needing layout, changing device orientation would kick the loop as well. + [self.view setNeedsLayout]; +} + +@end diff --git a/Examples/MMMSafeAreaGuide/UnsafeArea/main.m b/Examples/MMMSafeAreaGuide/UnsafeArea/main.m new file mode 100644 index 0000000..bee5456 --- /dev/null +++ b/Examples/MMMSafeAreaGuide/UnsafeArea/main.m @@ -0,0 +1,16 @@ +// +// Demo of iOS layout loop bug involving `safeAreaLayoutGuide` and transforms. +// Copyright (C) 2020, MediaMonks. +// + +#import +#import "AppDelegate.h" + +int main(int argc, char * argv[]) { + NSString * appDelegateClassName; + @autoreleasepool { + // Setup code that might create autoreleased objects goes here. + appDelegateClassName = NSStringFromClass([AppDelegate class]); + } + return UIApplicationMain(argc, argv, nil, appDelegateClassName); +} diff --git a/MMMCommonUI.podspec b/MMMCommonUI.podspec new file mode 100644 index 0000000..244c887 --- /dev/null +++ b/MMMCommonUI.podspec @@ -0,0 +1,47 @@ +# +# MMMCommonUI. Part of MMMTemple. +# Copyright (C) 2015-2020 MediaMonks. All rights reserved. +# + +Pod::Spec.new do |s| + + s.name = "MMMCommonUI" + s.version = "1.0.1" + s.summary = "Small UI-related pieces reused in many components from MMMTemple" + s.description = s.summary + s.homepage = "https://github.com/mediamonks/#{s.name}" + s.license = "MIT" + s.authors = "MediaMonks" + s.source = { :git => "https://github.com/mediamonks/#{s.name}.git", :tag => s.version.to_s } + + s.platform = :ios + s.ios.deployment_target = '11.0' + + s.subspec 'ObjC' do |ss| + ss.source_files = [ "Sources/#{s.name}ObjC/*.{h,m}" ] + ss.dependency 'MMMCommonCore/ObjC' + ss.dependency 'MMMLog/ObjC' + ss.dependency 'MMMObservables/ObjC' + ss.dependency 'MMMLoadable/ObjC' + end + + s.swift_versions = '4.2' + s.static_framework = true + s.pod_target_xcconfig = { + "DEFINES_MODULE" => "YES" + } + s.subspec 'Swift' do |ss| + ss.source_files = [ "Sources/#{s.name}/*.swift" ] + ss.dependency "#{s.name}/ObjC" + ss.dependency 'MMMCommonCore/Swift' + ss.dependency 'MMMLog/Swift' + ss.dependency 'MMMObservables/Swift' + ss.dependency 'MMMLoadable/Swift' + end + + s.test_spec 'Tests' do |ss| + ss.source_files = "Tests/*.{m,swift}" + end + + s.default_subspec = 'Swift' +end diff --git a/README.md b/README.md new file mode 100644 index 0000000..092b324 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# MMMCommonUI + +Small UI-related pieces reused in many components from MMMTemple. + +(This is a part of `MMMTemple` suite of iOS libraries we use at [MediaMonks](https://www.mediamonks.com/).) + +## Installation + +Podfile: + +``` +source 'https://github.com/mediamonks/MMMSpecs.git' +source 'https://github.com/CocoaPods/Specs.git' +... +pod 'MMMCommonUI' +``` + +(Use 'MMMCommonUI/ObjC' when Swift wrappers are not needed.) + +## Usage + +TBD + +--- diff --git a/Sources/MMMCommonUI/CommonUI.swift b/Sources/MMMCommonUI/CommonUI.swift new file mode 100644 index 0000000..54ef6c7 --- /dev/null +++ b/Sources/MMMCommonUI/CommonUI.swift @@ -0,0 +1,149 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +extension UITableViewCell { + /// A string that can be used as a reuse identifer for this cell, currently the name of the class. + /// (This is for the most common use case when all cells of the same type are considered equal.) + public static var defaultReuseIdentifier: String { return NSStringFromClass(self) } +} + +extension UITableView { + + /** + Dequeues a cell with the given identifier via `dequeueReusableCell(withIdentifier:)` + or creates a new one if the latter returns `nil`. + + This implements the common "try to dequeue first or create it if unavailable" pattern, which allows to avoid + registering cells in advance which in turn allows to avoid standard initializers. + + Example: + + ``` + let stepCell = _view.tableView.dequeueReusableCell(StepCell.defaultReuseIdentifier) { + StepCell() + } + ``` + */ + public func dequeueReusableCell( + _ identifier: String, + creationBlock: (_ identifier: String) -> CellType + ) -> CellType { + if let c = self.dequeueReusableCell(withIdentifier: identifier) as? CellType { + return c + } else { + return creationBlock(identifier) + } + } +} + +extension UICollectionViewCell { + + /// A string that can be used as a reuse identifer for this cell, currently the name of the class. + /// (This is for the most common use case when all cells of the same type are considered equal.) + public static var defaultReuseIdentifier: String { return NSStringFromClass(self) } +} + +extension UICollectionView { + + /** + Dequeues a cell with the given identifier via `dequeueReusableCell(withIdentifier:indexPath:)` + or creates a new one if the latter returns `nil`. + + This implements the common "try to dequeue first or create it if unavailable" pattern, which allows to avoid + registering cells in advance which in turn allows to avoid standard initializers. + + Example: + + ``` + let stepCell = _view.collectionView.dequeueReusableCell(StepCell.defaultReuseIdentifier, indexPath: indexPath) { + StepCell() + } + ``` + */ + public func dequeueReusableCell( + _ indentifier: String, + indexPath: IndexPath, + creationBlock: () -> CellType + ) -> CellType { + if let c = self.dequeueReusableCell(withReuseIdentifier: indentifier, for: indexPath) as? CellType { + return c + } else { + return creationBlock() + } + } +} + +/// Shortcuts for on-the-fly attribute tweaking. +extension Dictionary where Key == NSAttributedString.Key, Value == Any { + + /// Same attributes but merging ones from the given dictionary overriding the existing ones. + /// (Note that composite attributes such as paragraph style are not merged property by property.) + public func withAttributes(_ attributes: [NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any] { + var dictionary = self + dictionary.merge(attributes) { $1 } + return dictionary + } + + /// Same attributes but with the value of `.paragraphStyle` attribute adjusted by your closure. + /// (In case the original dictionary has no paragraph style attribute, then it's added.) + public func withParagraphStyle(block: (inout NSMutableParagraphStyle) -> Void) -> [NSAttributedString.Key: Any] { + + var dictionary = self + + var ps: NSMutableParagraphStyle = { + if let existing = self[.paragraphStyle] as? NSParagraphStyle { + return existing.mutableCopy() as! NSMutableParagraphStyle + } else { + return NSMutableParagraphStyle() + } + }() + + block(&ps) + + dictionary[.paragraphStyle] = ps + + return dictionary + } + + /// Same attributes but with paragraph style's alignment property changed to the specified value. + /// (In case the original dictionary has no paragraph style attribute, then it's added.) + public func withAlignment(_ alignment: NSTextAlignment) -> [NSAttributedString.Key: Any] { + return withParagraphStyle { $0.alignment = alignment } + } + + /// Same attributes but with the value of `.foregroundColor` set to the given value. + public func withColor(_ color: UIColor) -> [NSAttributedString.Key: Any] { + var dictionary = self + dictionary[.foregroundColor] = color + return dictionary + } +} + +extension UIEdgeInsets { + + /// Shorter initializer avoiding labels. + public init(_ top: CGFloat, _ left: CGFloat, _ bottom: CGFloat, _ right: CGFloat) { + self.init(top: top, left: left, bottom: bottom, right: right) + } + + /// Insets with all the components increased by the given value. + public func inset(by delta: CGFloat) -> UIEdgeInsets { + return UIEdgeInsets(top: top + delta, left: left + delta, bottom: bottom + delta, right: right + delta) + } + + /// Insets with all the components insets by the corresponding components of another insets object. + /// + /// Note that overloading the '+' operator would make it hard to discover. + public func inset(by insets: UIEdgeInsets) -> UIEdgeInsets { + return UIEdgeInsets( + top: top + insets.top, + left: left + insets.left, + bottom: bottom + insets.bottom, + right: right + insets.right + ) + } +} + +// MARK: - This is for misc stuff that is hard to group initially now. diff --git a/Sources/MMMCommonUI/MMMLayoutUtils.swift b/Sources/MMMCommonUI/MMMLayoutUtils.swift new file mode 100644 index 0000000..31a9c73 --- /dev/null +++ b/Sources/MMMCommonUI/MMMLayoutUtils.swift @@ -0,0 +1,40 @@ +// +// MMMTemple. +// Copyright (C) 2019 MediaMonks. All rights reserved. +// + +extension MMMLayoutUtils { + + /// Shortcut for `MMMLayoutUtils.centerMultiplier(forRatio: MMMLayoutUtils.inverseGolden)`. + public static var inverseGoldenMultiplier: CGFloat { + return centerMultiplier(forRatio: inverseGolden) + } + + /// Shortcut for `MMMLayoutUtils.centerMultiplier(forRatio: MMMLayoutUtils.golden)`. + public static var goldenMultiplier: CGFloat { + return centerMultiplier(forRatio: inverseGolden) + } +} + +extension UIView { + + /** + Adds constraints centering the given `view` within the receiver, ensuring same `minPadding` on the sides + and optionally limiting the width to `maxWidth`. + + This is a layout pattern commonly used with text-like content: + - the given view is centered within the receiver, + - certain minimum padding is ensured on the sides, + - the width of the view is limited to the given one so, let say the text does not become too wide on iPad + (if `maxWidth` is `0` or negative, then it's ignored). + */ + open func mmm_addConstraintsHorizontallyCentering(_ view: UIView, minPadding: CGFloat = 0, maxWidth: CGFloat = 0) { + self.__mmm_addConstraintsHorizontallyCentering(view, minPadding: minPadding, maxWidth: maxWidth) + } + + /// Similar to `mmm_addConstraints(horizontallyCenteringView:minPadding:maxWidth:)` but returns constraints + /// without adding them. + open func mmm_constraintsHorizontallyCentering(_ view: UIView, minPadding: CGFloat, maxWidth: CGFloat) -> [NSLayoutConstraint] { + return self.__mmm_constraintsHorizontallyCentering(view, minPadding: minPadding, maxWidth: maxWidth) + } +} diff --git a/Sources/MMMCommonUI/MMMStylesheet.swift b/Sources/MMMCommonUI/MMMStylesheet.swift new file mode 100644 index 0000000..cd026a6 --- /dev/null +++ b/Sources/MMMCommonUI/MMMStylesheet.swift @@ -0,0 +1,14 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +/// Swift additions for `MMMStylesheet`. +extension MMMStylesheet { + + /// More compact version of `insets(fromRelativeInsets:)` which was taking more space to write in Swift + /// due to the labels needed in the initializer of `UIEdgeInsets`. + public func insetsFromRelativeInsets(_ top: CGFloat, _ left: CGFloat, _ bottom: CGFloat, _ right: CGFloat) -> UIEdgeInsets { + return self.insets(fromRelativeInsets: UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)) + } +} diff --git a/Sources/MMMCommonUI/NonStoryboardables.swift b/Sources/MMMCommonUI/NonStoryboardables.swift new file mode 100644 index 0000000..88f08c6 --- /dev/null +++ b/Sources/MMMCommonUI/NonStoryboardables.swift @@ -0,0 +1,91 @@ +// +// MMMTemple. +// Copyright (C) 2019 MediaMonks. All rights reserved. +// + +/// Called from those initializers required by `NSCoding` that we often don't support. +/// +/// Example: +/// ``` +/// required init?(coder aDecoder: NSCoder) { storyboardsNotSupported(by: type(of: self)) } +/// ``` +public func storyboardsNotSupported(by type: AnyClass, file: StaticString = #file, line: UInt = #line) -> Never { + preconditionFailure( + "\(String(reflecting: type)) does not support decoding from storyboards/NIBs", file: file, line: line + ) +} + +/// A base for views that do not support NIBs/storyboards, so there is no need in defining +/// `required init?(coder:)` initializer. It also declares Auto Layout support and resets +/// `translatesAutoresizingMaskIntoConstraints`. +open class NonStoryboardableView: UIView { + + public init() { + super.init(frame: .zero) + self.translatesAutoresizingMaskIntoConstraints = false + } + + @available(*, unavailable) + required public init?(coder aDecoder: NSCoder) { storyboardsNotSupported(by: type(of: self)) } + + // Sometimes a custom view based on this class does not define internal constraints and thus does not work + // properly if somebody just asks about its preferred size (something happening in MMMTestCase), so we have to hint + // the system that Auto Layout should be used. + // (Using `class` instead of `static` to work around an invalid warning in Swift 4.2.) + @objc open override class var requiresConstraintBasedLayout: Bool { + return true + } +} + +/// A base for controls that do not support NIBs/storyboards, so there is no need in defining +/// `required init?(coder:)` initializer. +open class NonStoryboardableControl: UIControl { + + public init() { + super.init(frame: .zero) + self.translatesAutoresizingMaskIntoConstraints = false + } + + @available(*, unavailable) + required public init?(coder aDecoder: NSCoder) { storyboardsNotSupported(by: type(of: self)) } + + @objc open override class var requiresConstraintBasedLayout: Bool { + return true + } +} + +/// A base for table view cells that do not support NIBs/storyboards, so there is no need in defining +/// `required init?(coder:)` initializer. +open class NonStoryboardableTableViewCell: UITableViewCell { + + public init(reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + } + + @available(*, unavailable) + required public init?(coder aDecoder: NSCoder) { storyboardsNotSupported(by: type(of: self)) } +} + +/// A base for collection view cells that do not support NIBs/storyboards, so there is no need in defining +/// `required init?(coder:)` initializer. +open class NonStoryboardableCollectionViewCell: UICollectionViewCell { + + public override init(frame: CGRect) { + super.init(frame: frame) + } + + @available(*, unavailable) + required public init?(coder aDecoder: NSCoder) { storyboardsNotSupported(by: type(of: self)) } +} + +/// A base for view controllers that do not support NIBs/storyboards, so there is no need in defining +/// `required init?(coder:)` initializer. +open class NonStoryboardableViewController: UIViewController { + + public init() { + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required public init?(coder aDecoder: NSCoder) { storyboardsNotSupported(by: type(of: self)) } +} diff --git a/Sources/MMMCommonUIObjC/MMMAnimations.h b/Sources/MMMCommonUIObjC/MMMAnimations.h new file mode 100644 index 0000000..89f230b --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMAnimations.h @@ -0,0 +1,200 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +@import UIKit; + +NS_ASSUME_NONNULL_BEGIN + +/** + * A little helper for velocity/acceleration calculations: you feed it values with timestamps and can get the most recent + * acceleration/velocity values. + */ +@interface MMMVelocityMeter : NSObject + +/** Resets the state of the meter, all values added before are forgotten. */ +- (void)reset; + +/** Adds a coordinate and a corresponding timestamp. */ +- (void)addValue:(CGFloat)value timestamp:(NSTimeInterval)timestamp; + +/** Adds a coordinate with the current timstamp. */ +- (void)addValue:(CGFloat)value; + +/** Calculates velocity and acceleration based on recently added values. */ +- (void)calculateVelocity:(CGFloat *)velocity acceleration:(CGFloat *)acceleration; + +@end + +typedef NS_ENUM(NSInteger, MMMAnimationCurve) { + + MMMAnimationCurveLinear, + MMMAnimationCurveEaseOut, + MMMAnimationCurveEaseIn, + MMMAnimationCurveEaseInOut, + + // "Softer" versions are closer to the linear curve. + MMMAnimationCurveSofterEaseIn, + MMMAnimationCurveSofterEaseOut, + MMMAnimationCurveSofterEaseInOut +}; + +/** Animation curve opposite to the given one, e.g. EaseIn for EaseOut. */ +extern MMMAnimationCurve MMMReverseAnimationCurve(MMMAnimationCurve curve); + +/** + * Minimalistic animation helpers. + * + * Terminology: + * - Normalized time — time value from the [0; 1] range. + * - Curved time — normalized time transformed using one of the predefined animation curves. + */ +@interface MMMAnimation : NSObject + +/** Time obtained by curving the given normalized time (from [0; 1] range). */ ++ (CGFloat)curvedTimeForTime:(CGFloat)time curve:(MMMAnimationCurve)curve; + +/** + * Inverse function for curvedTimeForTime:curve:, i.e. when we know the value returned by curvedTimeForTime:curve: + * and want the time value passed there. + * This should be used sparingly (not every frame) as the implementation is no very efficient. + */ ++ (CGFloat)timeForCurvedTime:(CGFloat)time curve:(MMMAnimationCurve)curve; + +/** + * Time obtained by clamping the given time into [startTime; startTime + duration], normalizing to [0; 1] range, + * and then curving using a preset curve. + */ ++ (CGFloat)curvedTimeForTime:(CGFloat)t startTime:(CGFloat)startTime duration:(CGFloat)duration curve:(MMMAnimationCurve)curve; + +/** A float between 'from' and 'to' corresponding to already normalized and curved time. */ ++ (CGFloat)interpolateFrom:(CGFloat)from to:(CGFloat)to time:(CGFloat)time; + +/** This has been renamed. Use the version above. */ ++ (CGFloat)interpolateFrom:(CGFloat)from to:(CGFloat)to curvedTime:(CGFloat)time DEPRECATED_ATTRIBUTE; + +/** + * Value between two floats corresponding to the given time and timing curve. + * If the time is less then startTime, then 'from' is returned. + * If the time is greater then startTime + duration, then 'to' is returned. + */ ++ (CGFloat)interpolateFrom:(CGFloat)from to:(CGFloat)to time:(CGFloat)time startTime:(CGFloat)startTime duration:(CGFloat)duration curve:(MMMAnimationCurve)curve; + +/** + * A color between 'from' and 'to' corresponding to already normalized and curved time. + * Only RGB colors are supported. + * Interpolation is done along a straight line in the RGB space. + */ ++ (UIColor *)colorFrom:(UIColor *)from to:(UIColor *)to time:(CGFloat)time; + +/** + * A point on the line between given points corresponding to already normalized and curved time. + */ ++ (CGPoint)pointFrom:(CGPoint)from to:(CGPoint)to time:(CGFloat)time; + +@end + +@class MMMAnimationHandle; + +/** + * Called on every update cycle of MMMAnimator for the given animation item. + * + * The time is always within [0; 1] range here, which will correspond to the the [start; start + duration] interval of + * real time clock. + * + * Unless the item is cancelled it is guaranteed that the block will be called for 0 and 1 values. + */ +typedef void (^MMMAnimatorUpdateBlock)(MMMAnimationHandle *item, CGFloat time); + +/** + * Called when the animation item has been finished. + */ +typedef void (^MMMAnimatorDoneBlock)(MMMAnimationHandle *item, BOOL cancelled); + +/** + * Minimalistic animator object in the spirit of helpers defined in MMMAnimation. + * + * You add animation items, which are basically a set of blocks that will be called every frame on the main run loop and + * when it's done or cancelled. + * + * It's not for every case, it's for those moments when you know the duration in advance and just need to animate a + * simple custom property and don't want to subclass CALayer or mess with its multithreaded delegates. + * + * The animator object does not take care of interpolation of values nor time curves, the normalized time passed into + * update blocks can be transformed and values can be interpolated using simple helpers in MMMAnimation. + */ +@interface MMMAnimator : NSObject + ++ (instancetype)shared; + +/** + * Schedules a new animation item. + * + * The `updateBlock` is called on every update cycle within the animation's duration. It is guaranteed to be called with + * zero time even if cancelled before the next run loop cycle. The update block is also guaranteed to be called with + * time being 1 unless is cancelled earlier. + * + * The `doneBlock` is called after the animation finishes or is cancelled. + * + * The `repeatCount` parameter can be set to 0 to mean infinite repeat count. + * + * In case `repeatCount` is different from 1, then `autoreverse` influences the way the time changes when passed to + * the `updateBlock`: if YES, then it'll grow from 0 to 1 and then from 1 to 0 on the next repeat, changing back to + * from 0 to 1 after this, etc; if NO, then it'll always from from 0 to 1 on every repeat step. + * + * The animation will start on the next cycle of the refresh timer and will have the timestamp of this cycle as its + * actual start time, so there is no need in explicit transactions: all animation added on the same run loop cycle are + * guaranteed to be run in sync. + * + * Keep the object returned. The animation stops when the reference to this object is released. + */ +- (MMMAnimationHandle *)addAnimationWithDuration:(CGFloat)duration + updateBlock:(MMMAnimatorUpdateBlock)updateBlock + doneBlock:(MMMAnimatorDoneBlock)doneBlock; + +- (MMMAnimationHandle *)addAnimationWithDuration:(CGFloat)duration + repeatCount:(NSInteger)repeatCount + autoreverse:(BOOL)autoreverse + updateBlock:(MMMAnimatorUpdateBlock)updateBlock + doneBlock:(MMMAnimatorDoneBlock)doneBlock; + +/** Despite the +shared method defined above you can still create own instances of this class. */ +- (id)init NS_DESIGNATED_INITIALIZER; + +@end + +@interface MMMAnimator () + +/** + * For unit tests only: will synchronously run all the animations already in the animator and the ones added within + * the given block in the specified number of steps, executing the given block after each step. + * This is used in view-based tests for those views that run all their animations using MMMAnimator. + * + * The idea is that an animated action is triggered in the `animationsBlock` (e.g. `hideAnimated:YES`) and then the + * `stepBlock` is called in the very beginning and in exactly `numberOfSteps - 1` moments afterwards. The moments will be + * selected, so they are spaced equally and the last one is exactly at the end of the longest animation item. + */ +- (void)_testRunInNumberOfSteps:(NSInteger)numberOfSteps + animations:(void (NS_NOESCAPE ^)(void))animationsBlock + forEachStep:(void (NS_NOESCAPE ^)(NSInteger stepIndex))stepBlock; + +@end + +/** + * Sort of a handle returned by MMMAnimator when a new animation is scheduled. + * Keep it around, otherwise the animation will be cancelled. + */ +@interface MMMAnimationHandle : NSObject + +/** YES, if the animation has not been finished yet. */ +@property (nonatomic, readonly) BOOL inProgress; + +/** Finishes animation before its designated end time. */ +- (void)cancel; + +- (id)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMAnimations.m b/Sources/MMMCommonUIObjC/MMMAnimations.m new file mode 100644 index 0000000..a0b4d8f --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMAnimations.m @@ -0,0 +1,664 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMAnimations.h" + +#import "MMMCommonUI.h" + +typedef struct { + NSTimeInterval timestamp; + CGFloat value; +} MMMVelocityMeterSample; + +#define MMMVelocityMeterMaxSamples 3 + +@implementation MMMVelocityMeter { + NSInteger _numberOfSamples; + NSInteger _lastSampleIndex; + MMMVelocityMeterSample _samples[MMMVelocityMeterMaxSamples]; +} + +- (id)init { + if (self = [super init]) { + } + return self; +} + +- (void)reset { + _numberOfSamples = 0; + _lastSampleIndex = -1; +} + +- (MMMVelocityMeterSample)sampleAtIndex:(NSInteger)index { + NSInteger i = _lastSampleIndex - index; + if (i < 0) + i += MMMVelocityMeterMaxSamples; + return _samples[i]; +} + +- (void)addValue:(CGFloat)value { + [self addValue:value timestamp:[NSDate timeIntervalSinceReferenceDate]]; +} + +- (void)addValue:(CGFloat)value timestamp:(NSTimeInterval)timestamp { + + MMMVelocityMeterSample sample; + sample.timestamp = timestamp; + sample.value = value; + + if (_numberOfSamples > 0 && [self sampleAtIndex:0].timestamp == timestamp) { + + // Let's protect against same timestamps just in case by rewriting the last entry. + _samples[_lastSampleIndex] = sample; + + } else { + + _lastSampleIndex++; + if (_lastSampleIndex >= MMMVelocityMeterMaxSamples) + _lastSampleIndex -= MMMVelocityMeterMaxSamples; + _samples[_lastSampleIndex] = sample; + + if (_numberOfSamples < MMMVelocityMeterMaxSamples) + _numberOfSamples++; + } +} + +- (void)calculateVelocity:(CGFloat *)velocity acceleration:(CGFloat *)acceleration { + + CGFloat v = 0; + CGFloat a = 0; + + if (_numberOfSamples <= 1) { + + v = a = 0; + + } else if (_numberOfSamples == 2) { + + MMMVelocityMeterSample s0 = [self sampleAtIndex:0]; + MMMVelocityMeterSample s1 = [self sampleAtIndex:1]; + v = (s0.value - s1.value) / (s0.timestamp - s1.timestamp); + a = 0; + + } else if (_numberOfSamples >= 3) { + + MMMVelocityMeterSample s0 = [self sampleAtIndex:0]; + MMMVelocityMeterSample s1 = [self sampleAtIndex:1]; + MMMVelocityMeterSample s2 = [self sampleAtIndex:2]; + + CGFloat v0 = (s0.value - s1.value) / (s0.timestamp - s1.timestamp); + CGFloat v1 = (s1.value - s2.value) / (s1.timestamp - s2.timestamp); + a = (v0 - v1) / (s0.timestamp - s1.timestamp); + v = v0; + } + + if (velocity) + *velocity = v; + if (acceleration) + *acceleration = a; +} + +@end + +// +// +// +MMMAnimationCurve MMMReverseAnimationCurve(MMMAnimationCurve curve) { + + switch (curve) { + + case MMMAnimationCurveLinear: + case MMMAnimationCurveEaseInOut: + case MMMAnimationCurveSofterEaseInOut: + return curve; + + case MMMAnimationCurveEaseOut: + return MMMAnimationCurveEaseIn; + case MMMAnimationCurveEaseIn: + return MMMAnimationCurveEaseOut; + + case MMMAnimationCurveSofterEaseIn: + return MMMAnimationCurveSofterEaseOut; + case MMMAnimationCurveSofterEaseOut: + return MMMAnimationCurveSofterEaseIn; + } +} + +// +// +// +@implementation MMMAnimation + +// We'll define our normal animation curves via "ease in". +// The larger the 'k' coefficient the greater slope our ease function will have at t = 1. +static inline CGFloat MMMAnimationUtils_EaseIn(const CGFloat t, const CGFloat k) { + CGFloat tt = t * t; + return (k - 2) * t * tt + (3 - k) * tt; +} + +static inline CGFloat MMMAnimationUtils_BaseCurve(CGFloat time, MMMAnimationCurve curve, CGFloat k) { + + switch (curve) { + + case MMMAnimationCurveLinear: + return time; + + case MMMAnimationCurveEaseIn: + case MMMAnimationCurveSofterEaseIn: + return MMMAnimationUtils_EaseIn(time, k); + + case MMMAnimationCurveEaseOut: + case MMMAnimationCurveSofterEaseOut: + return 1 - MMMAnimationUtils_EaseIn(1 - time, k); + + case MMMAnimationCurveEaseInOut: + case MMMAnimationCurveSofterEaseInOut: + if (time <= .5f) { + return .5f * MMMAnimationUtils_EaseIn(time / .5f, k); + } else { + return 1 - (.5f * MMMAnimationUtils_EaseIn(1 - (time - .5f) / .5f, k)); + } + + default: + NSCAssert(NO, @""); + return 0; + } +} + ++ (CGFloat)timeForCurvedTime:(CGFloat)time curve:(MMMAnimationCurve)curve { + + // Assuming all our curves are monotonous, so we can use simple binary search here. + CGFloat l = 0; + CGFloat r = 1; + do { + CGFloat m = (l + r) / 2; + CGFloat f = [self curvedTimeForTime:m curve:curve]; + if (f < time) { + l = m; + } else { + r = m; + } + } while (fabs(r - l) > 1e-6); + + return l; +} + ++ (CGFloat)curvedTimeForTime:(CGFloat)time curve:(MMMAnimationCurve)curve { + + // Similar to `interpolateFrom:to:time`, the time was expected to be in [0; 1] range before, + // however for bounce-type animations it's handy to allow it to go outside the range. + // Extending the domain of the function here simply by not curving values outside the range. + if (time <= 0 || 1 < time) + return time; + + switch (curve) { + + case MMMAnimationCurveLinear: + case MMMAnimationCurveEaseIn: + case MMMAnimationCurveEaseOut: + case MMMAnimationCurveEaseInOut: + return MMMAnimationUtils_BaseCurve(time, curve, 2.5); + + case MMMAnimationCurveSofterEaseIn: + case MMMAnimationCurveSofterEaseOut: + case MMMAnimationCurveSofterEaseInOut: + return MMMAnimationUtils_BaseCurve(time, curve, 1.25); + } +} + ++ (CGFloat)curvedTimeForTime:(CGFloat)t startTime:(CGFloat)startTime duration:(CGFloat)duration curve:(MMMAnimationCurve)curve { + + NSAssert(duration > 0, @"Positive duration is expected in %s", sel_getName(_cmd)); + + CGFloat time = (t - startTime) / duration; + if (time < 0) + time = 0; + else if (time > 1) + time = 1; + + return [self curvedTimeForTime:time curve:curve]; +} + ++ (CGFloat)interpolateFrom:(CGFloat)from to:(CGFloat)to curvedTime:(CGFloat)time { + return [self interpolateFrom:from to:to time:time]; +} + ++ (CGFloat)interpolateFrom:(CGFloat)from to:(CGFloat)to time:(CGFloat)time { + // Note that we were asserting before that the time had to be within [0; 1]. This was meant to catch possible + // issues only as interpolation could be done (and be useful) with the time out of this range. + return from * (1 - time) + to * time; +} + ++ (CGFloat)interpolateFrom:(CGFloat)from to:(CGFloat)to time:(CGFloat)time + startTime:(CGFloat)startTime duration:(CGFloat)duration curve:(MMMAnimationCurve)curve +{ + CGFloat t = [self curvedTimeForTime:time startTime:startTime duration:duration curve:curve]; + return from * (1 - t) + to * t; +} + ++ (UIColor *)colorFrom:(UIColor *)from to:(UIColor *)to time:(CGFloat)time { + + CGFloat c1[4]; + CGFloat c2[4]; + + if ([from getRed:&c1[0] green:&c1[1] blue:&c1[2] alpha:&c1[3]] + && [to getRed:&c2[0] green:&c2[1] blue:&c2[2] alpha:&c2[3]] + ) { + return [UIColor + colorWithRed:[MMMAnimation interpolateFrom:c1[0] to:c2[0] time:time] + green:[MMMAnimation interpolateFrom:c1[1] to:c2[1] time:time] + blue:[MMMAnimation interpolateFrom:c1[2] to:c2[2] time:time] + alpha:[MMMAnimation interpolateFrom:c1[3] to:c2[3] time:time] + ]; + } else if ([from getWhite:&c1[0] alpha:&c1[1]] && [to getWhite:&c2[0] alpha:&c2[1]]) { + return [UIColor + colorWithWhite:[MMMAnimation interpolateFrom:c1[0] to:c2[0] time:time] + alpha:[MMMAnimation interpolateFrom:c1[1] to:c2[1] time:time] + ]; + } else { + NSAssert(NO, @"%s: both colors should use the same space (either RGB or grayscale)", sel_getName(_cmd)); + return from; + } +} + ++ (CGPoint)pointFrom:(CGPoint)from to:(CGPoint)to time:(CGFloat)time { + return CGPointMake( + [self interpolateFrom:from.x to:to.x time:time], + [self interpolateFrom:from.y to:to.y time:time] + ); +} + +@end + +// +// +// + +typedef NS_ENUM(NSInteger, MMMAnimatorItemState) { + + /** The item is added to the animator, but has not got a start time assigned and the corresponding block + * has not been called yet. */ + MMMAnimatorItemStateIdle, + + /** The animation corresponding to this item is in progress. The update block has been called at least once. */ + MMMAnimatorItemStateStarted, + + /** The animation corresponding to this item has been finished. + * The update block has been called at least once and the done block has been called as well. */ + MMMAnimatorItemStateFinished +}; + +// +// +// +@interface MMMAnimatorItem : NSObject + +@property (nonatomic, readonly) MMMAnimator *animator; + +@property (nonatomic, readwrite) MMMAnimatorItemState state; + +@property (nonatomic, readonly) NSTimeInterval duration; + +/** The timestamp of the first frame when the animation has been started. */ +@property (nonatomic, readwrite) NSTimeInterval timestamp; + +@property (nonatomic, readonly) MMMAnimatorUpdateBlock updateBlock; + +@property (nonatomic, readonly) MMMAnimatorDoneBlock doneBlock; + +@property (nonatomic, readonly) NSInteger repeatCount; + +@property (nonatomic, readonly) BOOL autoreverse; + +- (id)initWithAnimator:(MMMAnimator *)animator + duration:(CGFloat)duration + repeatCount:(NSInteger)repeatCount + autoreverse:(BOOL)autoreverse + updateBlock:(MMMAnimatorUpdateBlock)updateBlock + doneBlock:(MMMAnimatorDoneBlock)doneBlock NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; + +@end + +@implementation MMMAnimatorItem { + MMMAnimator * __weak _animator; +} + +- (id)initWithAnimator:(MMMAnimator *)animator + duration:(CGFloat)duration + repeatCount:(NSInteger)repeatCount + autoreverse:(BOOL)autoreverse + updateBlock:(MMMAnimatorUpdateBlock)updateBlock + doneBlock:(MMMAnimatorDoneBlock)doneBlock +{ + if (self = [super init]) { + + _animator = animator; + + _duration = duration; + NSAssert(_duration > 0, @"Invalid duration of the animation"); + + _updateBlock = updateBlock; + NSAssert(_updateBlock != nil, @"The update block has to be provided"); + + _repeatCount = repeatCount; + if (_repeatCount <= 0) + _repeatCount = INT_MAX; + + _autoreverse = autoreverse; + + _doneBlock = doneBlock; + } + + return self; +} + +@end + +@interface MMMAnimationHandle () + +@property (nonatomic, readonly) MMMAnimatorItem *item; + +- (id)initWithItem:(MMMAnimatorItem *)item NS_DESIGNATED_INITIALIZER; + +@end + +@interface MMMAnimator () + +/** This is the method directly for the handle. */ +- (void)cancelAnimationForHandle:(MMMAnimationHandle *)handle; + +@end + +@implementation MMMAnimationHandle + +- (id)initWithItem:(MMMAnimatorItem *)item { + + if (self = [super init]) { + _item = item; + } + + return self; +} + +- (void)dealloc { + [self cancel]; +} + +- (BOOL)inProgress { + return (_item.state != MMMAnimatorItemStateFinished); +} + +- (void)cancel { + + if (_item.state == MMMAnimatorItemStateFinished) + return; + + MMMAnimator *animator = _item.animator; + if (animator) { + [animator cancelAnimationForHandle:self]; + } +} + +@end + +// +// +// +@implementation MMMAnimator { + + CADisplayLink *_displayLink; + + // A mapping of NSValue-wrapped weak handles into the actual animation items. + NSMutableDictionary *_items; + + // + // To be able to override the shared animator from unit tests. + // + NSInteger _testingCounter; + NSTimeInterval _testTime; +} + +static MMMAnimator *sharedInstance = nil; + ++ (instancetype)shared { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[self alloc] init]; + }); + return sharedInstance; +} + +- (id)init { + + if (self = [super init]) { + + NSAssert( + [NSRunLoop currentRunLoop] == [NSRunLoop mainRunLoop], + @"%@ must be accessed from the main run loop", self.class + ); + + _items = [[NSMutableDictionary alloc] init]; + + _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(update)]; + [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; + [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode]; + } + + return self; +} + +- (void)dealloc { + + for (NSValue *key in [_items allKeys]) { + MMMAnimatorItem *item = _items[key]; + [self finalizeItem:item forKey:key cancelled:YES]; + } + + [_displayLink invalidate]; +} + +- (void)update { + + NSTimeInterval timestamp = _displayLink.timestamp; + + if (_testingCounter > 0) + timestamp = _testTime; + + NSMutableArray *keysToRemove = nil; + for (NSValue *key in [_items allKeys]) { + + MMMAnimatorItem *item = _items[key]; + if (!item) + continue; + + NSInteger repeatCount; + CGFloat localTime; + if (item.state == MMMAnimatorItemStateIdle) { + + // The item was scheduled on a previous run loop cycle, let's assign a timestamp to it. + item.timestamp = timestamp; + item.state = MMMAnimatorItemStateStarted; + localTime = 0; + repeatCount = 0; + + } else { + + CGFloat time = (timestamp - item.timestamp) / item.duration; + if (time < 0) + time = 0; + if (time > item.repeatCount) + time = item.repeatCount; + + repeatCount = (NSInteger)floor(time); + localTime = time - repeatCount; + if (repeatCount == item.repeatCount) + localTime = 1; + if (item.autoreverse && repeatCount % 2 == 1) + localTime = 1 - localTime; + } + + item.updateBlock([key nonretainedObjectValue], localTime); + + // If that was the last update, then remember the item to remove later. + if (item.repeatCount >= 0 && repeatCount >= item.repeatCount) { + if (!keysToRemove) { + keysToRemove = [[NSMutableArray alloc] init]; + } + [keysToRemove addObject:key]; + } + } + + if ([keysToRemove count] > 0) { + + for (NSValue *key in keysToRemove) { + + MMMAnimatorItem *item = _items[key]; + if (item) + [self finalizeItem:item forKey:key cancelled:NO]; + } + + [self pauseOrResumeUpdates]; + } +} + +- (void)pauseOrResumeUpdates { + _displayLink.paused = (_testingCounter > 0) || (_items.count == 0); +} + +- (MMMAnimationHandle *)addAnimationWithDuration:(CGFloat)duration + updateBlock:(MMMAnimatorUpdateBlock)updateBlock + doneBlock:(MMMAnimatorDoneBlock)doneBlock +{ + return [self + addAnimationWithDuration:duration + repeatCount:1 + autoreverse:NO + updateBlock:updateBlock + doneBlock:doneBlock + ]; +} + +- (MMMAnimationHandle *)addAnimationWithDuration:(CGFloat)duration + repeatCount:(NSInteger)repeatCount + autoreverse:(BOOL)autoreverse + updateBlock:(MMMAnimatorUpdateBlock)updateBlock + doneBlock:(MMMAnimatorDoneBlock)doneBlock +{ + NSAssert( + [NSRunLoop currentRunLoop] == [NSRunLoop mainRunLoop], + @"%@ must be accessed from the main run loop", self.class + ); + + MMMAnimatorItem *item = [[MMMAnimatorItem alloc] + initWithAnimator:self + duration:duration + repeatCount:repeatCount + autoreverse:autoreverse + updateBlock:updateBlock + doneBlock:doneBlock + ]; + + MMMAnimationHandle *handle = [[MMMAnimationHandle alloc] initWithItem:item]; + + NSValue *key = [NSValue valueWithNonretainedObject:handle]; + + _items[key] = item; + + [self pauseOrResumeUpdates]; + + return handle; +} + +- (void)finalizeItem:(MMMAnimatorItem *)item forKey:(id)key cancelled:(BOOL)cancelled { + + if (item.state == MMMAnimatorItemStateIdle || item.state == MMMAnimatorItemStateStarted) { + + // In case the item has not been active yet we still need to call the update block for time being 0 before we + // remove it, so at least the initial update of the corresponding property can be made. + if (item.state == MMMAnimatorItemStateIdle) { + item.updateBlock([key nonretainedObjectValue], 0); + } + + item.state = MMMAnimatorItemStateFinished; + + if (item.doneBlock) + item.doneBlock([key nonretainedObjectValue], cancelled); + + } else if (item.state == MMMAnimatorItemStateFinished) { + + // Already finished, nothing to do + + } else { + + NSAssert(NO, @""); + } + + [_items removeObjectForKey:key]; +} + +- (void)cancelAnimationForHandle:(MMMAnimationHandle *)handle { + + NSAssert( + [NSRunLoop currentRunLoop] == [NSRunLoop mainRunLoop], + @"%@ must be accessed from the main run loop", self.class + ); + + NSValue *key = [NSValue valueWithNonretainedObject:handle]; + + MMMAnimatorItem *item = _items[key]; + if (item) { + [self finalizeItem:item forKey:key cancelled:YES]; + } +} + +#pragma mark - + +- (void)_testRunInNumberOfSteps:(NSInteger)numberOfSteps + animations:(void (NS_NOESCAPE^)(void))animationsBlock + forEachStep:(void (NS_NOESCAPE^)(NSInteger stepIndex))stepBlock +{ + NSAssert(numberOfSteps >= 2, @""); + + if (++_testingCounter == 0) { + [self pauseOrResumeUpdates]; + } + + animationsBlock(); + + // Make sure all the current animation items will get the initial timestamp. + _testTime = 0; + [self update]; + + // Very first step, to not call update twice. + stepBlock(0); + + // Find the most distant end of animation. + NSTimeInterval maxTime = 0; + for (MMMAnimatorItem *item in [_items allValues]) { + + //~ NSAssert(item.repeatCount == 1, @"We don't support repeatCount for animations tested via %s", sel_getName(_cmd)); + + NSTimeInterval endTime = item.timestamp + item.duration; + if (endTime > maxTime) { + maxTime = endTime; + } + } + + // Go through all the steps. + for (NSInteger i = 1; i < numberOfSteps; i++) { + + _testTime = maxTime * i / (numberOfSteps - 1); + [self update]; + + stepBlock(i); + } + + if (--_testingCounter == 0) { + [self pauseOrResumeUpdates]; + } +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMAutoLayoutScrollView.h b/Sources/MMMCommonUIObjC/MMMAutoLayoutScrollView.h new file mode 100644 index 0000000..a462085 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMAutoLayoutScrollView.h @@ -0,0 +1,53 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class MMMAutoLayoutScrollViewContentView; +@class MMMScrollViewShadowsSettings; + +/** + * A vertical scroll view with a content view and preconfigured constraints, so there is no need in creating + * a scroll view / content view sandwitch manually every time. + * + * It also supports top and bottom shadows that are displayed only when the content is clipped. + * The shadows can be enabled individually and they can sit either flush with the edges of the scroll view + * or can be inset according to `adjustedContentInset`, which can be handy when vertical `safeAreaInsets` need + * to be taken into account. (Note that `contentInsetAdjustmentBehavior` has to be either `None` or `Always` + * on this view since "automatic" options can lead to cyclic calculations.) Also note that scroll indicators + * are disabled here by default. + * + * Begin by adding your controls and constraints into the `contentView` ensuring that its size can be derived from your + * constraints alone. Avoid constraints to the scroll view itself or outside views unless you are prepared to deal + * with the consequences. + * + * Note that the width of the `contentView` will be constrainted hard to be equal to the width of the scroll view + * and its height will be constrained with prio 251 to be at least as large as the height of the scroll view. + */ +@interface MMMAutoLayoutScrollView : UIScrollView + +/** This is where your content subviews should be added. */ +@property (nonatomic, readonly) MMMAutoLayoutScrollViewContentView *contentView; + +/** Initializes with the given config. + * Note that changing the config after the initialization has no effect on the view. */ +- (id)initWithSettings:(MMMScrollViewShadowsSettings *)settings NS_DESIGNATED_INITIALIZER; + +/** Initializes with default settings, a shortcut for `initWithSettings:[[MMMScrollViewShadowsSettings alloc] init]`. */ +- (id)init; + +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; + +@end + +/** A subview of MMMAutoLayoutScrollView where all the subviews should be added. + * (It's not different from UIView, but making it of its own class helps when browsing view hierarchies.) */ +@interface MMMAutoLayoutScrollViewContentView : UIView +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMAutoLayoutScrollView.m b/Sources/MMMCommonUIObjC/MMMAutoLayoutScrollView.m new file mode 100644 index 0000000..b33db35 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMAutoLayoutScrollView.m @@ -0,0 +1,197 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMAutoLayoutScrollView.h" + +#import "MMMAnimations.h" +#import "MMMCommonUI.h" +#import "MMMLayout.h" +#import "MMMScrollViewShadows.h" + +// +// Note that it's not much different from a bare view, but having it in a separate class makes it look nicer in the hierarchy browser +// +@implementation MMMAutoLayoutScrollViewContentView + + - (id)init { + + if (self = [super initWithFrame:CGRectZero]) { + self.opaque = NO; + self.translatesAutoresizingMaskIntoConstraints = NO; + } + + return self; + } + +@end + +/** A view that is used for additional clipping of scroll view's contents in case shadows do not sit flush + * with the edges of their scroll view. (Again, a normal UIView, but handy to have its own class name when + * browsing the hierarchies.) */ +@interface MMMAutoLayoutScrollViewClippingView : UIView +@end + +@implementation MMMAutoLayoutScrollViewClippingView +@end + +// +// +// +@implementation MMMAutoLayoutScrollView { + + // YES, if we don't expect subviews or constraints added anymore. + BOOL _hierarchyLocked; + + MMMScrollViewShadows *_shadows; + + NSLayoutConstraint *_heightConstraint; + + // Need additional clipping in case the shadows are not flush with the scroll view's frame. + MMMAutoLayoutScrollViewClippingView *_clippingView; +} + +- (id)init { + return [self initWithSettings:[[MMMScrollViewShadowsSettings alloc] init]]; +} + +- (id)initWithSettings:(MMMScrollViewShadowsSettings *)settings { + + if (self = [super initWithFrame:CGRectMake(0, 0, 320, 400)]) { + + self.translatesAutoresizingMaskIntoConstraints = NO; + + self.showsHorizontalScrollIndicator = self.showsVerticalScrollIndicator = NO; + + if (@available(iOS 11.0, *)) { + // The default 'Automatic' behavior can cause a layout calculation loop as described + // in `adjustMinHeightConstraintTakingInsetsIntoAccount` below. + self.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentAlways; + } + + _shadows = [[MMMScrollViewShadows alloc] initWithScrollView:self settings:settings]; + + // We need additional content clipping in case shadows might be not flush with the scroll view frame. + if ([_shadows mightNeedClippingView]) { + _clippingView = [[MMMAutoLayoutScrollViewClippingView alloc] init]; + _clippingView.translatesAutoresizingMaskIntoConstraints = YES; + [self addSubview:_clippingView]; + } + + _contentView = [[MMMAutoLayoutScrollViewContentView alloc] init]; + [(_clippingView ?: self) addSubview:_contentView]; + + // + // Layout + // + [self + mmm_addConstraintsAligningView:_contentView + horizontally:MMMLayoutHorizontalAlignmentFill + vertically:MMMLayoutVerticalAlignmentFill + ]; + + // We want to stretch the content view to fill the bounds in case its natural size is smaller than the scroll view's. + [self addConstraint:[NSLayoutConstraint + constraintWithItem:_contentView attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeWidth + multiplier:1 constant:0 + priority:UILayoutPriorityRequired + identifier:@"MMM-ContentView-Width" + ]]; + + _heightConstraint = [NSLayoutConstraint + constraintWithItem:_contentView attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationGreaterThanOrEqual + toItem:self attribute:NSLayoutAttributeHeight + multiplier:1 constant:0 + priority:UILayoutPriorityDefaultLow + 1 + identifier:@"MMM-ContentView-Minimum-Height" + ]; + _heightConstraint.active = YES; + + // Let's lock the hierarchy to be able to catch common misuse issues earlier. + _hierarchyLocked = YES; + } + + return self; +} + +- (void)adjustMinHeightConstraintTakingInsetsIntoAccount { + + /* + * There is a circular dependency with adjustedContentInset that makes the app freeze. It occurs when + * contentInsetAdjustmentBehavior is Automatic or ScrollableAxes. + * + * Example: a full screen scroll view on iPhone X with its content view requiring little space. + * + * 1) Our initial constraints cause the actual height of the content view to become equal to the screen height and + * thus the system adjusts the top content inset to 44pt so the content view can be fully scrolled back and forth + * from behind the status bar area. + * + * 2) Then we come here and change the height constraint allowing the content view to be 44pt smaller than the frame + * of the scroll view. + * + * 3) The content view of this size does not underlap the top safe area anymore and is always visible without + * scrolling, so the insets do not need to be adjusted now, i.e. they are set back to 0pt. + * + * 4) This triggers our code here again adjusting the minimum height constraint to that of the whole scroll view, + * and we are back at step #1. + */ + UIEdgeInsets contentInsets; + if (@available(iOS 11.0, *)) { + if (self.contentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentNever + || self.contentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentAlways + ) { + contentInsets = self.adjustedContentInset; + } else { + contentInsets = self.contentInset; + } + } else { + contentInsets = self.contentInset; + } + + _heightConstraint.constant = - (contentInsets.top + contentInsets.bottom); +} + +- (void)layoutSubviews { + + [self adjustMinHeightConstraintTakingInsetsIntoAccount]; + + [super layoutSubviews]; + + [_shadows layoutSubviewsWithClippingView:_clippingView]; +} + +#pragma mark - A bit of diagnostics + +- (void)setContentInsetAdjustmentBehavior:(UIScrollViewContentInsetAdjustmentBehavior)contentInsetAdjustmentBehavior { + NSAssert( + contentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentNever || contentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentAlways, + @"`contentInsetAdjustementBehavior` set to 'Automatic' or similar can cause calculation dependency loops with `adjustedContentInset`. Use either 'Always' or 'Never' instead." + ); + [super setContentInsetAdjustmentBehavior:contentInsetAdjustmentBehavior]; +} + +- (void)addSubview:(UIView *)view { + NSAssert( + !_hierarchyLocked || [NSStringFromClass([view class]) hasPrefix:@"UI"], + @"Add your subviews into `contentView` and not into %@ directly", self.class + ); + [super addSubview:view]; +} + +- (void)addConstraints:(NSArray<__kindof NSLayoutConstraint *> *)constraints { + NSAssert(!_hierarchyLocked, @"Add your constraints into `contentView` and not into %@ directly", self.class); + [super addConstraints:constraints]; +} + +- (void)addConstraint:(NSLayoutConstraint *)constraint { + NSAssert(!_hierarchyLocked, @"Add your constraints into `contentView` and not into %@ directly", self.class); + [super addConstraint:constraint]; +} + +#pragma mark + +@end diff --git a/Sources/MMMCommonUIObjC/MMMCollectionView.h b/Sources/MMMCommonUIObjC/MMMCollectionView.h new file mode 100644 index 0000000..70f4189 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMCollectionView.h @@ -0,0 +1,27 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +#import "MMMScrollViewShadows.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * Collection view supporting top & bottom shadows. + */ +@interface MMMCollectionView : UICollectionView + +/** Uses UICollectionViewFlowLayout by default. */ +- (instancetype)initWithSettings:(MMMScrollViewShadowsSettings *)settings NS_DESIGNATED_INITIALIZER; + +- (instancetype)initWithFrame:(CGRect)frame collectionViewLayout:(UICollectionViewLayout *)layout NS_UNAVAILABLE; +- (instancetype)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMCollectionView.m b/Sources/MMMCommonUIObjC/MMMCollectionView.m new file mode 100644 index 0000000..6cf9feb --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMCollectionView.m @@ -0,0 +1,29 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMCollectionView.h" + +@implementation MMMCollectionView { + MMMScrollViewShadows *_shadows; +} + +- (instancetype)initWithSettings:(MMMScrollViewShadowsSettings *)settings { + + if (self = [super initWithFrame:CGRectMake(0, 0, 320, 400) collectionViewLayout:[[UICollectionViewFlowLayout alloc] init]]) { + + self.translatesAutoresizingMaskIntoConstraints = NO; + + _shadows = [[MMMScrollViewShadows alloc] initWithScrollView:self settings:settings]; + } + + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + [_shadows layoutSubviews]; +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMCommonUI.h b/Sources/MMMCommonUIObjC/MMMCommonUI.h new file mode 100644 index 0000000..eb19c4e --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMCommonUI.h @@ -0,0 +1,360 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +@import UIKit; +@import MMMCommonCore; // Technically not needed in this header but then does not have to import on the use side. + +NS_ASSUME_NONNULL_BEGIN + +/** + * Returns a color from a debug palette that can be used to highlight views for diagnostics purposes. + * For the same index the same color is returned, however the total number of different colors + * is limited to just a few, i.e. any given index is mapped into an index of a limited palette of colors. + */ +extern UIColor *MMMDebugColor(NSInteger index); + +/** + * Draws a rectangle lying completely inside of the specified rect taking into account line width. + * It is possible to select which of the 4 edges of the rectangle will be drawn, + * and what color and line width should be used. + */ +extern void MMMDrawBorder(CGRect r, UIRectEdge edge, UIColor *color, CGFloat width); + +/** + * Returns the size decreased by the specified insets: the width is reduced by (insets.left + insets.right) + * and the height by (insets.top + insets.bottom). + * + * This is somewhat like size of the rect returned by + * \code + * UIEdgeInsetsInsetRect(CGRectMake(0, 0, size.width, size.height), insets) + * \endcode + * + * (Except that width and height never go negative of course). + * + * This is handy to use in sizeThatFits: + */ +static inline CGSize MMMDeflateSize(CGSize size, UIEdgeInsets insets) { + return CGSizeMake( + MAX(0, size.width - insets.left - insets.right), + MAX(0, size.height - insets.top - insets.bottom) + ); +} + +/** + * Inverse of MMMDeflateSize(): the insets are added to the given size instead of being subtracted. + */ +static inline CGSize MMMInflateSize(CGSize size, UIEdgeInsets insets) { + return CGSizeMake( + insets.left + size.width + insets.right, + insets.top + size.height + insets.bottom + ); +} + +/** + * The smallest insets that (component-wise) are not smaller than either of the provided ones. + */ +static inline UIEdgeInsets MMMMaxUIEdgeInsets(UIEdgeInsets a, UIEdgeInsets b) { + return UIEdgeInsetsMake(MAX(a.top, b.top), MAX(a.left, b.left), MAX(a.bottom, b.bottom), MAX(a.right, b.right)); +} + +/** + * The smallest size that is not smaller than either of the provided ones. + */ +static inline CGSize MMMMaxCGSize(CGSize a, CGSize b) { + return CGSizeMake(MAX(a.width, b.width), MAX(a.height, b.height)); +} + +/** + * A sum of two insets object. + */ +static inline UIEdgeInsets MMMCombinedUIEdgeInsets(UIEdgeInsets a, UIEdgeInsets b) { + return UIEdgeInsetsMake(a.top + b.top, a.left + b.left, a.bottom + b.bottom, a.right + b.right); +} + +/** + * UIEdgeInsets object with all fields being equal to the given value. + */ +static inline UIEdgeInsets MMMSymmetricalUIEdgeInsets(CGFloat value) { + return UIEdgeInsetsMake(value, value, value, value); +} + +/** + * A caching shortcut to [UIScreen mainScreen].scale used by MMMPixelRound(). + */ +extern CGFloat MMMPixelScale(void); + +/** + * Rounds the given value in points so the corresponding value in pixels (assuming the main screen scale) + * is rounded to the nearest integer. + */ +static inline CGFloat MMMPixelRound(CGFloat pointValue) { + const CGFloat scale = MMMPixelScale(); + return roundf(pointValue * scale) / scale; +} + +/** + * Rounds the given value in points so the corresponding value in pixels (assuming the main screen scale) + * is rounded to the nearest larger integer. + */ +static inline CGFloat MMMPixelCeil(CGFloat pointValue) { + const CGFloat scale = MMMPixelScale(); + return ceilf(pointValue * scale) / scale; +} + +/** + * Rounds the given value in points so the corresponding value in pixels (assuming the main screen scale) + * is rounded to the nearest smaller nteger. + */ +static inline CGFloat MMMPixelFloor(CGFloat pointValue) { + const CGFloat scale = MMMPixelScale(); + return floorf(pointValue * scale) / scale; +} + +/** + * Size with components rounded to the closest larger integral values. Sort of missing CGSize analogue of CGIntegralRect. + * + * See MMMPixelIntegralSize() for pixel boundary alignment. + */ +static inline CGSize MMMIntegralSize(CGSize size) { + return CGSizeMake(ceilf(size.width), ceilf(size.height)); +} + +/** + * A version of MMMIntegralSize() taking into account the scale of the main screen, so on 2x Retina it will round up to 0.5 points. + */ +static inline CGSize MMMPixelIntegralSize(CGSize size) { + return CGSizeMake(MMMPixelCeil(size.width), MMMPixelCeil(size.height)); +} + +/** + * A version of CGIntegralRect() taking into account the scale of the main screen. + */ +static inline CGRect MMMPixelIntegralRect(CGRect r) { + return CGRectMake( + MMMPixelRound(r.origin.x), MMMPixelRound(r.origin.y), + MMMPixelCeil(r.size.width), MMMPixelCeil(r.size.height) + ); +} + +/** + * The length of the vector represented by the given point. + */ +static inline CGFloat MMMPointVectorLength(CGPoint p) { + return sqrtf(p.x * p.x + p.y * p.y); +} + +/** + * The distance between two points. + */ +static inline CGFloat MMMPointDistance(CGPoint p1, CGPoint p2) { + CGFloat dx = p1.x - p2.x; + CGFloat dy = p1.y - p2.y; + return sqrtf(dx * dx + dy * dy); +} + +/** + * Translates UIViewAnimationCurve into a corresponding flag of UIViewAnimationOptions. + * Handy when we know the curve at runtime, like in keyboard appearance handlers, and want to use + * the corresponding UIViewAnimationOptions flags. + */ +static inline UIViewAnimationOptions MMMAnimationOptionsFromAnimationCurve(UIViewAnimationCurve curve) { + // Not very clean, but will work + return (UIViewAnimationOptions)(curve << 16); +} + +/** + * Wrapper for NSAttributedString HTML parsing functionality. + * Can be used for simple HTML, having only paragraphs, bullets and some emphasized text. + * The result is mutable, so you can directly adjust it. + * + * @param baseAttributes These attributes are applied to the whole string after the parsing. + * @param regularAttributes Attributes applied to regular (non-bold) text. + * @param emphasizedAttributes Attributes applied to emphasized parts of the string. + */ +extern NSMutableAttributedString *MMMParseSimpleHTML( + NSString *text, + NSDictionary *baseAttributes, + NSDictionary *regularAttributes, + NSDictionary *emphasizedAttributes +); + +/** Possible values for `MMMCaseTransformAttributeName` attribute. */ +typedef NSString *MMMCaseTransform NS_TYPED_EXTENSIBLE_ENUM; + +/** Part of the string marked with this should not change case before being rendered. */ +extern MMMCaseTransform const MMMCaseTransformOriginal; + +/** Part of the string marked with this should be UPPERCASED before being rendered. */ +extern MMMCaseTransform const MMMCaseTransformUppercased; + +/** + * Name of the attribute defining how the case of text should be transformed before being rendered. + * + * Note that this is our custom attribute, there is no support for it at the level of Core Text or our views. + * You have to use `mmm_attributedStringApplyingCaseTransformUsingLocale:` in order to apply this attribute + * to the strings you use. + */ +extern NSAttributedStringKey const MMMCaseTransformAttributeName NS_SWIFT_NAME(caseTransform); + +@interface NSAttributedString (MMMTempleMMMCommonUI) + +/** + * Returns a string where transforms specified using `MMMCaseTransformAttributeName` are applied. + * + * Note that the attribute itself is not removed. + */ +- (NSAttributedString *)mmm_attributedStringApplyingCaseTransformWithLocale:(NSLocale *)locale + NS_SWIFT_NAME(mmm_attributedStringApplyingCaseTransform(withLocale:)); + +@end + +@interface NSDictionary (MMMTempleMMMCommonUI) + +/** + * The result of combination of attributes from this dictionary and another one. + * The attributes from another dictionary take precedance. + */ +- (NSDictionary *)mmm_withAttributes:(NSDictionary *)attributes; + +/** Attributes dictionary with the given color added under NSForegroundColorAttributeName key. */ +- (NSDictionary *)mmm_withColor:(UIColor *)color; + +/** Attributes dictionary with the paragraph style adjusted by the given block. */ +- (NSDictionary *)mmm_withParagraphStyle:(void (^)(NSMutableParagraphStyle *ps))block; + +/** Attributes dictionary with the paragraph style's alignment set to the given value. */ +- (NSDictionary *)mmm_withAlignment:(NSTextAlignment)alignment; + +@end + +// +// +// +@interface UIColor (MMMTempleMMMCommonUI) + +/** YES, if the color's alpha component is less than 1. */ +- (BOOL)mmm_isTransparent; + +/** + * A color from a CSS-like static string literal. Supports hex style only for now. + * + * Note that this version is designed for constant literals known at compilation time, + * so it'll not just return nil, but assert-crash in DEBUG in case the literal cannot be parsed. + * Use `mmm_colorWithString:error:` for dynamic strings and handle the errors properly. + */ ++ (instancetype)mmm_colorWithString:(NSString *)string; + +/** + * A color from a CSS-like string. Supports hex style only for now. + * + * Unlike `mmm_colorWithString:` this will never assert-crash, but will return nil and set the optional + * error object pointer instead. + */ ++ (instancetype)mmm_colorWithString:(NSString *)s error:(NSError * __autoreleasing *)error; + +@end + +/** + * The height of the top area covered by the application status bar for the given rectangle in the bounds of the + * specified view. It's always greater than or equal to zero. + * It can be used to manually adjust the insets of a scroll view which is covered by the status bar. + */ +extern CGFloat MMMHeightOfAreaCoveredByStatusBar(UIView *view, CGRect rect); + +// +// +// +@interface UIImage (MMMTempleMMMCommonUI) + +/** + * Rasterized version of the given PDF image scaled to the given height and tinted with the given color. + * + * @param height Height of the resulting image; pass 0 to use the actual rounded height of the PDF. + * @param tintColor Color to fill the non-transparent pixels with; pass `nil` to avoid tinting. + * + * Note that the file must be stored outside of the asset catalogue because we cannot get a raw PDF from there. + */ ++ (UIImage *)mmm_imageFromPDFNamed:(NSString *)name + rasterizedForHeight:(CGFloat)height + tintColor:(nullable UIColor *)tintColor; + +/** + * A shortcut for `mmm_imageFromPDFNamed:rasterizedForHeight:tintColot` without tinting + * (passing `nil` for `tintColor`). + */ ++ (UIImage *)mmm_imageFromPDFNamed:(NSString *)name rasterizedForHeight:(CGFloat)height; + +/** + * A shortcut for `mmm_imageFromPDFNamed:rasterizedForHeight:tintColot` without scaling + * (passing 0 for `height`). + */ ++ (UIImage *)mmm_imageFromPDFNamed:(NSString *)name tintColor:(UIColor *)tintColor; + +/** + * A non-caching version of `mmm_imageFromPDFNamed:rasterizedForHeight:tintColor:` using a concrete file path. + */ ++ (UIImage *)mmm_imageFromPDFWithPath:(NSString *)path rasterizedForHeight:(CGFloat)height tintColor:(nullable UIColor *)tintColor; + +/** + * Image of the given size in points and color, possibly transparent. + */ ++ (UIImage *)mmm_rectangleWithSize:(CGSize)size color:(UIColor *)color NS_SWIFT_NAME(mmm_rectangle(size:color:)); + +/** Makes a 1 by 1 point image with the given color, possibily transparent. */ ++ (UIImage *)mmm_singlePixelWithColor:(UIColor *)color NS_SWIFT_NAME(mmm_singlePixel(color:)); + +@end + +// +// +// +@interface UIViewController (MMMTempleMMMCommonUI) + +/** + * A drop-in replacement for `presentViewController:animated:completion:` helping to disable iOS 13-style interactive + * dismissal for the view controllers we originally intended to display modally. + * + * Instead of setting `isModalInPresentation` on the view controller being presented to `YES` (which should have been + * default), that we did initially, it is now setting `modalPresentationStyle` to `UIModalPresentationFullScreen` + * to avoid interactive dismissal to mess up our layout and gestures. + */ +- (void)mmm_modallyPresentViewController:(UIViewController *)viewControllerToPresent + animated:(BOOL)flag + completion:(nullable void (^)(void))completion; + +@end +/** + * Calculates phase for a dashed line so that the ends of the line are cut symmetrically and at the dashed parts of the pattern. + * `lineLength` is the total length of the line. + * `dashLength` and `skipLength` are the length of the dashed and skipped parts of the pattern. + */ +extern CGFloat MMMPhaseForDashedPattern(CGFloat lineLength, CGFloat dashLength, CGFloat skipLength); + +/** + * Adds a path for a dashed line circle into the current graphics context and sets the given line dash pattern + * (via CGContextSetLineDash) adjusting it a bit so the pattern will match seamlessly. + * You need to stroke the path yourself. + */ +extern void MMMAddDashedCircle(CGPoint center, CGFloat radius, CGFloat dashLength, CGFloat skipLength); + +/** YES, if running under Fastlane's Snapshot tool. */ +static inline BOOL MMMIsRunningUnderFastlane() { + +#if DEBUG + + static BOOL result; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + result = [[NSUserDefaults standardUserDefaults] boolForKey:@"FASTLANE_SNAPSHOT"]; + }); + return result; + +#else + // Always assume standalone execution for Release builds. + return NO; +#endif +} + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMCommonUI.m b/Sources/MMMCommonUIObjC/MMMCommonUI.m new file mode 100644 index 0000000..50db969 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMCommonUI.m @@ -0,0 +1,586 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMCommonUI.h" + +@import MMMLog; +@import MMMCommonCore; + +UIColor *MMMDebugColor(NSInteger index) { + static dispatch_once_t onceToken; + static NSArray *colors; + dispatch_once(&onceToken, ^{ + // Taken from this palette: https://color.adobe.com/Vitamin-C-color-theme-492199/ + colors = @[ + [UIColor colorWithRed:0.0000 green:0.2627 blue:0.3451 alpha:1.0], + [UIColor colorWithRed:0.1216 green:0.5412 blue:0.4392 alpha:1.0], + [UIColor colorWithRed:0.7451 green:0.8588 blue:0.2235 alpha:1.0], + [UIColor colorWithRed:1.0000 green:0.8824 blue:0.1020 alpha:1.0], + [UIColor colorWithRed:0.9922 green:0.4549 blue:0.0000 alpha:1.0] + ]; + }); + return colors[(index * 6793) % colors.count]; +} + +void MMMDrawBorder(CGRect r, UIRectEdge edge, UIColor *color, CGFloat width) { + + CGFloat halfBorderWidth = width / 2; + CGRect borderRect = UIEdgeInsetsInsetRect( + r, + UIEdgeInsetsMake( + ((edge & UIRectEdgeTop) == 0) ? 0 : halfBorderWidth, + ((edge & UIRectEdgeLeft) == 0) ? 0 : halfBorderWidth, + ((edge & UIRectEdgeBottom) == 0) ? 0 : halfBorderWidth, + ((edge & UIRectEdgeRight) == 0) ? 0 : halfBorderWidth + ) + ); + + CGContextRef c = UIGraphicsGetCurrentContext(); + + // Note that we begin with the not shifted Y coordinate on purpose + CGContextMoveToPoint(c, CGRectGetMinX(borderRect), CGRectGetMinY(r)); + + if (edge & UIRectEdgeLeft) + CGContextAddLineToPoint(c, CGRectGetMinX(borderRect), CGRectGetMaxY(borderRect)); + else + CGContextMoveToPoint(c, CGRectGetMinX(borderRect), CGRectGetMaxY(borderRect)); + + if (edge & UIRectEdgeBottom) + CGContextAddLineToPoint(c, CGRectGetMaxX(borderRect), CGRectGetMaxY(borderRect)); + else + CGContextMoveToPoint(c, CGRectGetMaxX(borderRect), CGRectGetMaxY(borderRect)); + + if (edge & UIRectEdgeRight) + CGContextAddLineToPoint(c, CGRectGetMaxX(borderRect), CGRectGetMinY(borderRect)); + else + CGContextMoveToPoint(c, CGRectGetMaxX(borderRect), CGRectGetMinY(borderRect)); + + if (edge & UIRectEdgeTop) + CGContextAddLineToPoint(c, CGRectGetMinX(borderRect), CGRectGetMinY(borderRect)); + + CGContextSetLineCap(c, kCGLineCapButt); + CGContextSetLineJoin(c, kCGLineJoinMiter); + CGContextSetLineWidth(c, width); + + [color setStroke]; + CGContextStrokePath(c); +} + +CGFloat MMMPixelScale() { + static CGFloat scale = 1; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + scale = [UIScreen mainScreen].scale; + }); + return scale; +} + +NSMutableAttributedString *MMMParseSimpleHTML( + NSString *text, + NSDictionary *baseAttributes, + NSDictionary *regularAttributes, + NSDictionary *emphasizedAttributes +) { + + NSError *__autoreleasing error = nil; + + NSAttributedString *attributedString = [[NSAttributedString alloc] + initWithData:[text dataUsingEncoding:NSUTF8StringEncoding] + options:@{ + NSDocumentTypeDocumentAttribute : NSHTMLTextDocumentType, + NSCharacterEncodingDocumentAttribute : @(NSUTF8StringEncoding), + } + documentAttributes:nil + error:&error + ]; + + if (!attributedString) { + return nil; + } + + // + // We get default fonts and colors (e.g. Times New Roman/black) from the above conversion. + // Let's go through all the attributes and adjust the ones we are interested in. + // + NSMutableAttributedString *result = [attributedString mutableCopy]; + + [attributedString + enumerateAttributesInRange:NSMakeRange(0, attributedString.length) + options:0 + usingBlock:^(NSDictionary *attrs, NSRange range, BOOL *stop) { + + // Let's cleanup all the attributes first, except for links + for (NSString *attrName in attrs) { + if (![attrName isEqualToString:NSLinkAttributeName]) { + [result removeAttribute:attrName range:range]; + } + } + + // We need to preserve emphasized text, so let's look for bold weight font and replace it with ours. + UIFont *font = attrs[NSFontAttributeName]; + if (font) { + if (font.fontDescriptor.symbolicTraits & UIFontDescriptorTraitBold) { + [result addAttributes:emphasizedAttributes range:range]; + } else { + [result addAttributes:regularAttributes range:range]; + } + } + } + ]; + + // Note that we are adding base attributes afterwards, so they should not contains fonts, for example. + [result addAttributes:baseAttributes range:NSMakeRange(0, attributedString.length)]; + + // + // The conversion above can add extra newlines at the end of the resulting string, let's get rid of them + // + NSCharacterSet *charactersToTrim = [NSCharacterSet whitespaceAndNewlineCharacterSet]; + NSInteger i = result.length - 1; + while (i >= 0 && [charactersToTrim characterIsMember:[result.string characterAtIndex:i]]) + i--; + if (i < result.length) + [result deleteCharactersInRange:NSMakeRange(i + 1, result.length - (i + 1))]; + + return result; +} + +#pragma mark - MMMCaseTransform + +NSAttributedStringKey const MMMCaseTransformAttributeName = @"MMMCaseTransform"; + +MMMCaseTransform const MMMCaseTransformOriginal = @"original"; +MMMCaseTransform const MMMCaseTransformUppercased = @"uppercased"; + +@implementation NSAttributedString (MMMTempleMMMCommonUI) + +- (NSAttributedString *)mmm_attributedStringApplyingCaseTransformWithLocale:(NSLocale *)locale { + + NSMutableAttributedString *result = [[NSMutableAttributedString alloc] initWithAttributedString:self]; + + [result + enumerateAttribute:MMMCaseTransformAttributeName + inRange:NSMakeRange(0, result.length) + // Note that the longest effective range is not required for uppercasing, + // but will be needed for Title Case or similar transform. + options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired // Nice. 61 characters. + usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) { + + if (value == nil || [value isEqual:MMMCaseTransformOriginal]) { + + // Nothing to do, preserving the original case. + // (Note that we are called for the parts of the string where our attribute is not assigned, + // this is when the value is nil.) + + } else if ([value isEqual:MMMCaseTransformUppercased]) { + + [result + replaceCharactersInRange:range + withString:[[result.string substringWithRange:range] uppercaseStringWithLocale:locale] + ]; + + } else { + NSAssert(NO, @"Unsupported value for '%@' attribute: '%@'", MMMCaseTransformAttributeName, value); + } + } + ]; + + return result; +} + +@end + +@implementation NSDictionary (MMMTempleMMMCommonUI) + +- (NSDictionary *)mmm_withAttributes:(NSDictionary *)attributes { + NSMutableDictionary *result = [self mutableCopy]; + [result addEntriesFromDictionary:attributes]; + return result; +} + +- (NSDictionary *)mmm_withColor:(UIColor *)color { + NSMutableDictionary *result = [[NSMutableDictionary alloc] initWithDictionary:self]; + result[NSForegroundColorAttributeName] = color; + return result; +} + +- (NSDictionary *)mmm_withParagraphStyle:(void (^)(NSMutableParagraphStyle *ps))block { + + NSMutableDictionary *result = [self mutableCopy]; + + NSMutableParagraphStyle *ps = result[NSParagraphStyleAttributeName]; + if (!ps) { + ps = [[NSMutableParagraphStyle alloc] init]; + result[NSParagraphStyleAttributeName] = ps; + } else if (![ps isKindOfClass:[NSMutableParagraphStyle class]]) { + result[NSParagraphStyleAttributeName] = [ps mutableCopy]; + } + + block(ps); + + return result; +} + +- (NSDictionary *)mmm_withAlignment:(NSTextAlignment)alignment { + + return [self mmm_withParagraphStyle:^(NSMutableParagraphStyle *ps) { + ps.alignment = alignment; + }]; +} + +@end + +#pragma mark - + +// +// +// +@implementation UIColor (MMMTempleMMMCommonUI) + +- (BOOL)mmm_isTransparent { + return CGColorGetAlpha(self.CGColor) < 1; +} + ++ (NSError *)mmm_colorWithStringErrorWithMessage:(NSString *)message { + return [NSError + errorWithDomain:@"UIColor+MMMTemple" + code:-1 + userInfo:@{ NSLocalizedDescriptionKey : message } + ]; +} + ++ (instancetype)mmm_colorWithString:(NSString *)s { + + NSError *error = nil; + + UIColor *result = [self mmm_colorWithString:s error:&error]; + + NSAssert( + result != nil, + @"Could not parse color literal '%@': %@. (Use %s if you want to catch errors)", + s, error.localizedDescription, + sel_getName(@selector(mmm_colorWithString:error:)) + ); + + return result; +} + ++ (instancetype)mmm_colorWithString:(NSString *)s error:(NSError * __autoreleasing *)error { + + static dispatch_once_t onceToken; + static NSCharacterSet *hexCharacters = nil; + dispatch_once(&onceToken, ^{ + hexCharacters = [NSCharacterSet characterSetWithCharactersInString:@"01234567890abcdefABCDEF"]; + }); + + NSScanner *scanner = [NSScanner scannerWithString:s]; + scanner.caseSensitive = NO; + + if ([scanner scanString:@"rgb" intoString:NULL]) { + + if (error) { + *error = [self mmm_colorWithStringErrorWithMessage:@"We don't support rgb() or rgba() formats"]; + } + + return nil; + + } else { + + // Assuming a hexadecimal string at this point, just skipping the leading hash, if any + [scanner scanString:@"#" intoString:NULL]; + + // We could use scanHexInt: directly, but we want to ensure it's exactly 6 character string to crash early if we encounter something suspicious + NSString *hex = nil; + if (![scanner scanCharactersFromSet:hexCharacters intoString:&hex] || [hex length] != 6) { + if (error) + *error = [self mmm_colorWithStringErrorWithMessage:@"Expected exactly 6 hexadecimal characters"]; + return nil; + } + + if (![scanner isAtEnd]) { + if (error) { + *error = [self mmm_colorWithStringErrorWithMessage:@"Got unexpected characters at the end of the input string"]; + } + return nil; + } + + // OK, now when we know the string consists of strictly 6 hex characters we still can use a scanner + unsigned result = 0; + if (![[NSScanner scannerWithString:hex] scanHexInt:&result]) { + if (error) { + *error = [self mmm_colorWithStringErrorWithMessage:@"Unexpected failure"]; + } + NSAssert(NO, @""); + return nil; + } + + return [UIColor + colorWithRed:((result >> 16) & 0xFF) / 255.0 + green:((result >> 8) & 0xFF) / 255.0 + blue:(result & 0xFF) / 255.0 + alpha:1 + ]; + } +} + +@end + +CGFloat MMMHeightOfAreaCoveredByStatusBar(UIView *view, CGRect rect) { + + CGRect statusBarRect = [view convertRect:[UIApplication sharedApplication].statusBarFrame fromView:nil]; + + // This is to work around the problem with UI rotations: it is possible that the status bar is still portrait + // while the view is already landscape, and it can also be hidden (zero height); + // in this case the height calculated as below will be large and will cause layout issues. + // Returning 0 now assumming that the view using this function will eventually recalculate the status bar height + // in response to UIApplicationDidChangeStatusBarFrameNotification. + if (CGRectGetHeight(statusBarRect) <= 0 || CGRectGetHeight(statusBarRect) > CGRectGetWidth(statusBarRect)) + return 0; + + return MAX(0, CGRectGetMaxY(statusBarRect) - CGRectGetMinY(rect)); +} + +// +// +// +@implementation UIImage (MMMTempleMMMCommonUI) + ++ (NSString *)mmm_cacheKeyForNamed:(NSString *)name height:(CGFloat)height tintColor:(UIColor *)tintColor { + + const CGFloat *components = CGColorGetComponents(tintColor.CGColor); + + NSMutableString *colorKey = [[NSMutableString alloc] init]; + for (NSInteger i = 0; i < CGColorGetNumberOfComponents(tintColor.CGColor); i++) { + [colorKey appendFormat:@"-%.3f", components[i]]; + } + + return [NSString stringWithFormat:@"%@-%.1f%@", name, height, colorKey]; +} + ++ (UIImage *)mmm_imageFromPDFWithPath:(NSString *)path rasterizedForHeight:(CGFloat)height tintColor:(UIColor *)tintColor { + + CGPDFDocumentRef document = nil; + CGPDFPageRef page = nil; + UIImage *resultImage = nil; + + do { + + document = CGPDFDocumentCreateWithURL((__bridge CFURLRef)[NSURL fileURLWithPath:path]); + if (!document) { + MMM_LOG_ERROR(@"Could not open image at '%@' as a PDF", MMMPathRelativeToAppBundle(path)); + NSAssert(NO, @""); + break; + } + + CGPDFPageRef page = CGPDFDocumentGetPage(document, 1); + if (!page) { + MMM_LOG_ERROR(@"Could not get the first page of the PDF document at '%@'", MMMPathRelativeToAppBundle(path)); + NSAssert(NO, @""); + break; + } + + CGRect pageRect = CGPDFPageGetBoxRect(page, kCGPDFCropBox); + + CGFloat roundedHeight = MMMPixelRound(height); + + CGFloat scale = roundedHeight <= 0 ? 1 : roundedHeight / pageRect.size.height; + + CGSize resultImageSize = CGSizeMake(MMMPixelRound(pageRect.size.width * scale), roundedHeight); + + UIGraphicsBeginImageContextWithOptions(resultImageSize, NO, 0); + + CGContextRef c = UIGraphicsGetCurrentContext(); + + if (tintColor) { + + CGContextSaveGState(c); + CGContextTranslateCTM(c, -pageRect.origin.x, -pageRect.origin.y + roundedHeight); + CGContextScaleCTM(c, scale, -scale); + CGContextDrawPDFPage(c, page); + CGContextRestoreGState(c); + + [tintColor setFill]; + CGContextSetBlendMode(c, kCGBlendModeSourceIn); + CGContextFillRect(c, CGRectMake(0, 0, resultImageSize.width, resultImageSize.height)); + + } else { + + CGContextTranslateCTM(c, -pageRect.origin.x, -pageRect.origin.y + roundedHeight); + CGContextScaleCTM(c, scale, -scale); + CGContextDrawPDFPage(c, page); + } + + resultImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + // The rendering mode UIImageRenderingModeAlwaysOriginal was selected before only when tintColor was provided. + // I am sure this will cause incompatibilities with the older code, but always using "original" seems more logical. + resultImage = [resultImage imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; + + } while (0); + + if (page) + CGPDFPageRelease(page); + if (document) + CGPDFDocumentRelease(document); + + return resultImage; +} + ++ (UIImage *)mmm_imageFromPDFNamed:(NSString *)name rasterizedForHeight:(CGFloat)height tintColor:(UIColor *)tintColor { + + static NSCache *cache = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + cache = [[NSCache alloc] init]; + cache.totalCostLimit = 1 * 1024 * 1024; + }); + + NSString *keyName = [self mmm_cacheKeyForNamed:name height:height tintColor:tintColor]; + + UIImage *result = [cache objectForKey:keyName]; + if (result) + return result; + + NSString *path = [[NSBundle mainBundle] pathForResource:name ofType:@"pdf"]; + if (!path) { + MMM_LOG_ERROR(@"Could not find image named '%@'", name); + NSAssert(NO, @""); + return nil; + } + + UIImage *resultImage = [self mmm_imageFromPDFWithPath:path rasterizedForHeight:height tintColor:tintColor]; + + if (resultImage) { + [cache setObject:resultImage forKey:keyName cost:resultImage.size.width * resultImage.size.height]; + } + + return resultImage; +} + ++ (UIImage *)mmm_imageFromPDFNamed:(NSString *)name tintColor:(UIColor *)tintColor { + return [self mmm_cacheKeyForNamed:name height:0 tintColor:tintColor]; +} + ++ (UIImage *)mmm_imageFromPDFNamed:(NSString *)name rasterizedForHeight:(CGFloat)height { + return [self mmm_imageFromPDFNamed:name rasterizedForHeight:height tintColor:nil]; +} + ++ (UIImage *)mmm_rectangleWithSize:(CGSize)size color:(UIColor *)color { + + UIGraphicsBeginImageContextWithOptions(size, NO, 0); + + [color setFill]; + CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, size.width, size.height)); + + UIImage *result = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + return result; +} + ++ (UIImage *)mmm_singlePixelWithColor:(UIColor *)color { + return [self mmm_rectangleWithSize:CGSizeMake(1, 1) color:color]; +} + +@end + +@implementation UIViewController (MMMTempleMMMCommonUI) + +- (void)mmm_modallyPresentViewController:(UIViewController *)viewController + animated:(BOOL)animated + completion:(void (^)(void))completion +{ + viewController.modalPresentationStyle = UIModalPresentationFullScreen; + [self presentViewController:viewController animated:animated completion:completion]; +} + +@end + +@implementation NSObject (MMMTempleMMMCommonUI) + +- (id)mmm_stripNSNull { + return ((id)self == [NSNull null]) ? nil : self; +} + +@end + +// +// +// +CGFloat MMMPhaseForDashedPattern(CGFloat lineLength, CGFloat dashLength, CGFloat skipLength) { + + // We want to tweak the phase so the start of the line looks (almost) the same as its end. + // The idea here is that in order for the line to be cut symmetrically either the center of the dash or the center + // of the skip part of the pattern should reside in the center of the line. We calculate two phases assuming either + // dashed or skipped part in the center and then picking the one leading to the cut on the dashed part of the pattern. + // Note that we don't want to cut in the middle of a pixel, that's why we have "pixel" rounds below. + CGFloat patternWidth = dashLength + skipLength; + + // Half of the line length before the dashed part in the center. + // | ---- ---- ... ---- --|-- ---- ... + // [ dw ] + CGFloat dw = (lineLength - dashLength) / 2 + patternWidth; + CGFloat phaseDash = -MMMPixelRound(dw - floor(dw / patternWidth) * patternWidth); + + // Half of the line length with the skipped part in the center. + // |-- ---- ---- ... ---- | ---- ... + // [ sw ] + CGFloat sw = (lineLength + skipLength) / 2 + patternWidth; + CGFloat phaseSkip = -MMMPixelRound(sw - floor(sw / patternWidth) * patternWidth); + + if (phaseDash >= -skipLength && phaseSkip >= -skipLength) { + // Let's try to make the skip smaller at least. + return MAX(phaseDash, phaseSkip); + } else { + // Maximizing the dashed part. + return MIN(phaseDash, phaseSkip); + } +} + +// +// +// +void MMMAddDashedCircle(CGPoint center, CGFloat radius, CGFloat dashLength, CGFloat skipLength) { + + CGContextRef context = UIGraphicsGetCurrentContext(); + + // + // Rendering the circle as a polygon, not as an ellipse, so we can control the number of segments it is divided + // into, know it's exact length, so the adjustment of dashed pattern below work. + // (No, it would not be 2 * M_PI with CGContextAddEllipseInRect().) + // + const NSInteger numberOfKnots = 64; + for (NSInteger i = 0; i < numberOfKnots; i++) { + double angle = 2 * M_PI * i / numberOfKnots; + CGPoint p = CGPointMake(center.x + radius * cos(angle), center.y + radius * sin(angle)); + if (i == 0) { + CGContextMoveToPoint(context, p.x, p.y); + } else { + CGContextAddLineToPoint(context, p.x, p.y); + } + } + CGFloat circleLength = numberOfKnots * 2 * radius * sin(M_PI / numberOfKnots); + CGContextClosePath(context); + + // + // Setting/ajusting the dash pattern. + // + CGFloat lengths[] = { dashLength, skipLength }; + + // Adjusting the dashed part of the pattern a little bit so it connects properly with the start of the circle. + CGFloat patternLength = lengths[0] + lengths[1]; + NSInteger fullPatterns = roundf(circleLength / patternLength); + CGFloat remainder = circleLength - fullPatterns * patternLength; + lengths[0] += remainder / fullPatterns; + + CGContextSetLineDash(context, 0, lengths, sizeof(lengths) / sizeof(lengths[0])); + + // + // Let's use reasonable defaults for the line's width and cap. + // The user's code has a chance to override this before stroking the path. + // + CGContextSetLineWidth(context, 1); + CGContextSetLineCap(context, kCGLineCapButt); +} diff --git a/Sources/MMMCommonUIObjC/MMMImageView.h b/Sources/MMMCommonUIObjC/MMMImageView.h new file mode 100644 index 0000000..d54b0ac --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMImageView.h @@ -0,0 +1,37 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * A limited replacement for UIImageView fixing its inability to properly work with images having + * non-zero alignmentRectInsets when scaled. + * + * Note that this view is already constrained to the aspect ratio of the image's alignment rect, + * so you should not use hard (equal) pins against both width and height or against all edges. + */ +@interface MMMImageView : UIView + +@property (nonatomic, readwrite, nullable) UIImage *image; +@property (nonatomic, readwrite, nullable) UIImage *highlightedImage; + +@property (nonatomic, readwrite, getter=isHighlighted) BOOL highlighted; + +- (id)initWithImage:(nullable UIImage *)image highlightedImage:(nullable UIImage *)highlightedImage NS_DESIGNATED_INITIALIZER; + +/** Convenience initializer. */ +- (id)init; + +/** Convenience initializer. */ +- (id)initWithImage:(nullable UIImage *)image; + +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMImageView.m b/Sources/MMMCommonUIObjC/MMMImageView.m new file mode 100644 index 0000000..e33a0df --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMImageView.m @@ -0,0 +1,134 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMImageView.h" + +#import "MMMCommonUI.h" +#import "MMMLayout.h" + +@implementation MMMImageView { + + // I need a subview that can go outside of our bounds and displays our image, UIImageView can still do this perfectly, + // we just don't let it play with the alignment rect. + UIImageView *_imageView; + + NSLayoutConstraint *_aspectRatioConstraint; +} + +- (id)init { + return [self initWithImage:nil highlightedImage:nil]; +} + +- (id)initWithImage:(UIImage *)image { + return [self initWithImage:image highlightedImage:nil]; +} + +- (id)initWithImage:(UIImage *)image highlightedImage:(UIImage *)highlightedImage { + + if (self = [super initWithFrame:CGRectZero]) { + + self.translatesAutoresizingMaskIntoConstraints = NO; + self.userInteractionEnabled = NO; + + _imageView = [[UIImageView alloc] init]; + [self addSubview:_imageView]; + + self.image = image; + self.highlightedImage = highlightedImage; + } + + return self; +} + +- (void)setContentMode:(UIViewContentMode)contentMode { + // TODO: we can support content modes too, aspect fit/fill could be useful + NSAssert(NO, @"We don't support %s", sel_getName(_cmd)); +} + +- (UIImage *)currentImage { + return self.highlighted ? (_highlightedImage ?: _image) : _image; +} + +- (void)imageDidChange { + + // Don't show it alignment rect, it cannot handle it. + _imageView.image = [[self currentImage] imageWithAlignmentRectInsets:UIEdgeInsetsZero]; + + [self invalidateIntrinsicContentSize]; + [self setNeedsUpdateConstraints]; + [self setNeedsLayout]; +} + +- (void)setImage:(UIImage *)image { + _image = image; + [self imageDidChange]; +} + +- (void)setHighlightedImage:(UIImage *)highlightedImage { + _highlightedImage = highlightedImage; + [self imageDidChange]; +} + +- (void)setHighlighted:(BOOL)highlighted { + if (_highlighted != highlighted) { + _highlighted = highlighted; + [self imageDidChange]; + } +} + +- (void)updateConstraints { + + [super updateConstraints]; + + _aspectRatioConstraint.active = NO; + + if (![self currentImage]) + return; + + CGSize s = [self intrinsicContentSize]; + _aspectRatioConstraint = [NSLayoutConstraint + constraintWithItem:self attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeHeight + multiplier:s.width / s.height constant:0 + ]; + _aspectRatioConstraint.active = YES; +} + +- (void)layoutSubviews { + + if (![self currentImage]) + return; + + CGRect b = self.bounds; + + CGSize imageAlignmentRectSize = [self intrinsicContentSize]; + + CGPoint scale = CGPointMake(b.size.width / imageAlignmentRectSize.width, b.size.height / imageAlignmentRectSize.height); + + UIEdgeInsets insets = _image.alignmentRectInsets; + insets.top *= scale.y; + insets.left *= scale.x; + insets.right *= scale.x; + insets.bottom *= scale.y; + + _imageView.frame = MMMPixelIntegralRect(CGRectMake( + b.origin.x - insets.left, + b.origin.y - insets.top, + insets.left + b.size.width + insets.right, + insets.top + b.size.height + insets.bottom + )); +} + +- (CGSize)intrinsicContentSize { + UIImage *image = [self currentImage]; + if (image) { + return MMMPixelIntegralSize(MMMDeflateSize(image.size, image.alignmentRectInsets)); + } else { + return CGSizeZero; + } +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMKeyboard.h b/Sources/MMMCommonUIObjC/MMMKeyboard.h new file mode 100644 index 0000000..6e3e80a --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMKeyboard.h @@ -0,0 +1,86 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +@import MMMObservables; + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, MMMKeyboardState) { + + /** + * We don't know for sure if the keyboard is hidden or not. + * There is no way to read this when the MMMKeyboard is created, so ensure you have an instance early enough. + */ + MMMKeyboardStateUnknown = 0, + + /** The keyboard is hidden or is being hid now. */ + MMMKeyboardStateHidden, + + /** The keyboard is visible now or is being shown now. */ + MMMKeyboardStateVisible +}; + +@protocol MMMKeyboardObserver; + +/** + * An object knowing the state and position of the keyboard and helping with layout of views + * that should not be overlapped by it. + */ +@interface MMMKeyboard : NSObject + +/** + * Normal shared and lazily initialized instance. + * It's benefitial to force creation of one early on startup so the state/position is known asap. + */ ++ (instancetype)shared; + +/** The current state of the keyboard. */ +@property (nonatomic, readonly) MMMKeyboardState state; + +/** + * In case the keyboard is visible, then bounds of the largest top part of the view not covered by the keyboard; + * in case it's hidden, then unchanged bounds of the view. + * + * Note that in case the view is covered by the keyboard completely, then the bounds of the view with the height + * set to zero are returned. + */ +- (CGRect)boundsNotCoveredByKeyboardForView:(UIView *)view; + +/** + * How the bounds rect of the given view should be inset so it is not covered by the keyboard. + * This can be handy to use with a scroll view, for example, to adjust its insets instead of a frame. + */ +- (UIEdgeInsets)insetsForBoundsNotCoveredByKeyboardForView:(UIView *)view; + +/** In case the keyboard is visible, then the height of the part covered by it; 0 when the keyboard is hidden. */ +- (CGFloat)heightOfPartCoveredByKeyboardForView:(UIView *)view; + +/** Adds an observer and returns a token corresponding to it. The observer is removed when the token is deallocated. */ +- (id)addObserver:(id)observer; + +@end + +@protocol MMMKeyboardObserver + +/** + * Called when the keyboard is about to appear or disappear. + * The duration of the animation and a corresponding animation curve can be used to coordinate the animation + * of the view listening to state changes. + * + * You can use MMMAnimationOptionsFromAnimationCurve() to use the curve parameter where UIViewAnimationOptions + * are expected. + * + * The 'MMMKeyboard#boundsNotCoveredByKeyboardForView:' method should be ready at this point to help with + * calculation of the obscured area. + */ +- (void)keyboard:(MMMKeyboard *)keyboard + willChangeStateWithAnimationDuration:(NSTimeInterval)duration + curve:(UIViewAnimationCurve)curve; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMKeyboard.m b/Sources/MMMCommonUIObjC/MMMKeyboard.m new file mode 100644 index 0000000..251f012 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMKeyboard.m @@ -0,0 +1,105 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMKeyboard.h" + +#import "MMMObserverHub.h" + +@implementation MMMKeyboard { + MMMObserverHub> *_observerHub; + CGRect _endFrame; +} + ++ (instancetype)shared { + + static MMMKeyboard *shared = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + shared = [[MMMKeyboard alloc] init]; + }); + return shared; +} + +- (id)init { + + if (self = [super init]) { + + _observerHub = [[MMMObserverHub alloc] initWithObservable:self]; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil]; + } + + return self; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +#pragma mark - + +- (CGRect)boundsNotCoveredByKeyboardForView:(UIView *)view { + + CGRect bounds = view.bounds; + + if (_state == MMMKeyboardStateUnknown || _state == MMMKeyboardStateHidden) { + // Well, the keyboard is hidden (or we assume it is) + return bounds; + } + + CGRect keyboardFrame = [view convertRect:_endFrame fromView:nil]; + if (CGRectGetMaxY(bounds) <= CGRectGetMinY(keyboardFrame)) { + // The keyboard is far below the view, we have all the bounds + return bounds; + } else { + // The keyboard is covering the view partially or wholly, let's adjust the height of the bounds not covered + bounds.size.height = MAX(CGRectGetMinY(keyboardFrame) - CGRectGetMinY(bounds), 0); + return bounds; + } +} + +- (CGFloat)heightOfPartCoveredByKeyboardForView:(UIView *)view { + CGRect bounds = view.bounds; + CGRect notCoveredBounds = [self boundsNotCoveredByKeyboardForView:view]; + return CGRectGetMaxY(bounds) - CGRectGetMaxY(notCoveredBounds); +} + +- (UIEdgeInsets)insetsForBoundsNotCoveredByKeyboardForView:(UIView *)view { + return UIEdgeInsetsMake(0, 0, [self heightOfPartCoveredByKeyboardForView:view], 0); +} + +#pragma mark - + +- (void)keyboardWillChange:(NSNotification *)n state:(MMMKeyboardState)state { + + NSDictionary *params = [n userInfo]; + + _endFrame = [params[UIKeyboardFrameEndUserInfoKey] CGRectValue]; + NSTimeInterval duration = [params[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + UIViewAnimationCurve curve = (UIViewAnimationCurve)[params[UIKeyboardAnimationCurveUserInfoKey] integerValue]; + + _state = state; + + [_observerHub forEachObserver:^(id observer) { + [observer keyboard:self willChangeStateWithAnimationDuration:duration curve:curve]; + }]; +} + +- (void)keyboardWillShow:(NSNotification *)n { + [self keyboardWillChange:n state:MMMKeyboardStateVisible]; +} + +- (void)keyboardWillHide:(NSNotification *)n { + [self keyboardWillChange:n state:MMMKeyboardStateHidden]; +} + +#pragma mark - + +- (id)addObserver:(id)observer { + return [_observerHub safeAddObserver:observer]; +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMLayout.h b/Sources/MMMCommonUIObjC/MMMLayout.h new file mode 100644 index 0000000..13de93c --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMLayout.h @@ -0,0 +1,555 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * This is to group a few simple layout helpers. + */ +@interface MMMLayoutUtils : NSObject + +/** + * A rect with the given size positioned inside of the target rect in such a way that anchor points of both rects align. + * + * Anchor points are given relative to the sizes of the corresponding rects, similar to CALayer's `anchorPoint` + * property. For example, `CGPointMake(0.5, 0.5)` represents a center of any rect; `CGPointMake(1, 0.5)` means + * the center point of the right vertical edge. + * + * Note that the origin of the rect returned is rounded to the nearest pixels (not points!). + * + * See `rectWithSize:inRect:contentMode:` for a shortcut supporting UIViewContentMode. + */ ++ (CGRect)rectWithSize:(CGSize)size anchor:(CGPoint)anchor withinRect:(CGRect)targetRect anchor:(CGPoint)targetAnchor; + +/** + * A shortcut for the above method with anchors being the same for both source and target rect. + * (This way the resulting rect will be always inside of the target one, assuming anchors are within [0; 1] range.) + */ ++ (CGRect)rectWithSize:(CGSize)size withinRect:(CGRect)targetRect anchor:(CGPoint)anchor; + +/** + * A frame for the `sourceRect` positioned within the `targetRect` according to standard `UIViewContentMode` flags + * related to the layout (i.e. all except `UIViewContentModeRedraw`). + * + * Note that the origin of the resulting rectangle is always rounded to the nearest pixel. + */ ++ (CGRect)rectWithSize:(CGSize)size withinRect:(CGRect)targetRect contentMode:(UIViewContentMode)contentMode + NS_SWIFT_NAME(rect(withSize:withinRect:contentMode:)); + +/** + * A frame of the given size with its center at the specified point (assuming the center is defined by the given anchor + * point). + * + * Note that the origin of the resulting rectangle is rounded to the nearest pixel boundary. + */ ++ (CGRect)rectWithSize:(CGSize)size atPoint:(CGPoint)center anchor:(CGPoint)anchor; + +/** Same as rectWithSize:center:anchor: with anchor set to (0.5, 0.5). */ ++ (CGRect)rectWithSize:(CGSize)size center:(CGPoint)center; + +@end + +/** + * Suppose you need to contrain a view so its center divides its container in certain ratio different from 1:1 + * (e.g. golden section): + * + * ┌─────────┐ ◆ + * │ │ │ + * │ │ │ a + * │┌───────┐│ │ + * ─│┼ ─ ─ ─ ┼│─◆ ratio = a / b + * │└───────┘│ │ + * │ │ │ + * │ │ │ + * │ │ │ b + * │ │ │ + * │ │ │ + * │ │ │ + * └─────────┘ ◆ + * + * You cannot put this ratio directly into the `multiplier` parameter of the corresponding NSLayoutConstraints relating + * the centers of the views, because the `multiplier` would be the ratio between the distance to the center + * of the view (`h`) and the distance to the center of the container (`H`) instead: + * + * ◆ ┌─────────┐ ◆ + * │ │ │ │ + * │ │ │ │ a = h + * H │ │┌───────┐│ │ + * │ │├ ─ ─ ─ ┼│─◆ multiplier = h / H + * │ │└───────┘│ │ ratio = a / b = h / (2 * H - h) + * ◆─│─ ─ ─ ─ ─│ │ + * │ │ │ + * │ │ │ b = 2 * H - h + * │ │ │ + * │ │ │ + * │ │ │ + * └─────────┘ ◆ + * + * I.e. the `multiplier` is h / H (assuming the view is the first in the definition of the constraint), + * but the ratio we are interested would be h / (2 * H - h) if expressed in the distances to centers. + * + * If you have a desired ratio and want to get a `multiplier`, which when applied, results in the layout dividing + * the container in this ratio, then you can use this function as shortcut. + * + * Detailed calculations: + * ratio = h / (2 * H - h) ==> 2 * H * ratio - h * ratio = h ==> 2 * H * ratio / h - ratio = 1 + * ==> 1 + ratio = 2 * H * ratio / h ==> (1 + ratio) / (2 * ratio) = H / h + * where H / h is the inverse of our `multiplier`, so the actual multiplier is (2 * ratio) / (1 + ratio). + */ +static +NS_SWIFT_NAME(MMMLayoutUtils.centerMultiplier(forRatio:)) +inline CGFloat MMMCenterMultiplierForRatio(CGFloat ratio) { + return (2 * ratio) / (1 + ratio); +} + +/** + * Auto Layout does not support relationships between empty spaces, so we need to use spacer views and set such + * constraints between them. This one is a transparent and by default hidden view which can be used as such a spacer. + * It has no intrinsic size and low content hugging and compression resistance priorities. + * Unlike UIView we have translatesAutoresizingMaskIntoConstraints set to NO already. + */ +@interface MMMSpacerView : UIView + +- (nonnull id)init NS_DESIGNATED_INITIALIZER; + +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; + +@end + +/** + * Auto Layout does not support constraints against groups of items, so this is for the cases a normal UIView is + * typically used as a contrainer for such a group. + * Unlike UIView we have translatesAutoresizingMaskIntoConstraints set to NO already. + */ +@interface MMMContainerView : UIView + +- (nonnull id)init NS_DESIGNATED_INITIALIZER; + +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; + +@end + +/** Golden ratio constant. */ +extern CGFloat const MMMGolden NS_SWIFT_NAME(MMMLayoutUtils.golden); + +/** 1 divided by golden ratio. */ +extern CGFloat const MMMInverseGolden NS_SWIFT_NAME(MMMLayoutUtils.inverseGolden); + +#define MMM_GOLDEN (MMMGolden) +#define MMM_INVERSE_GOLDEN (MMMInverseGolden) + +/** General alignment flags used when it's not important which direction (vertical or horizontal) the alignment is for. */ +typedef NS_ENUM(NSInteger, MMMLayoutAlignment) { + MMMLayoutAlignmentNone, + MMMLayoutAlignmentLeading, + MMMLayoutAlignmentGolden, + MMMLayoutAlignmentCenter, + MMMLayoutAlignmentTrailing, + MMMLayoutAlignmentFill +}; + +typedef NS_ENUM(NSInteger, MMMLayoutDirection) { + MMMLayoutDirectionHorizontal, + MMMLayoutDirectionVertical +}; + +typedef NS_ENUM(NSInteger, MMMLayoutHorizontalAlignment) { + MMMLayoutHorizontalAlignmentNone = MMMLayoutAlignmentNone, + MMMLayoutHorizontalAlignmentLeft = MMMLayoutAlignmentLeading, + MMMLayoutHorizontalAlignmentGolden = MMMLayoutAlignmentGolden, + MMMLayoutHorizontalAlignmentCenter = MMMLayoutAlignmentCenter, + MMMLayoutHorizontalAlignmentRight = MMMLayoutAlignmentTrailing, + MMMLayoutHorizontalAlignmentFill = MMMLayoutAlignmentFill +}; + +typedef NS_ENUM(NSInteger, MMMLayoutVerticalAlignment) { + MMMLayoutVerticalAlignmentNone = MMMLayoutAlignmentNone, + MMMLayoutVerticalAlignmentTop = MMMLayoutAlignmentLeading, + MMMLayoutVerticalAlignmentGolden = MMMLayoutAlignmentGolden, + MMMLayoutVerticalAlignmentCenter = MMMLayoutAlignmentCenter, + MMMLayoutVerticalAlignmentBottom = MMMLayoutAlignmentTrailing, + MMMLayoutVerticalAlignmentFill = MMMLayoutAlignmentFill +}; + +static inline MMMLayoutAlignment MMMLayoutAlignmentFromHorizontalAlignment(MMMLayoutHorizontalAlignment alignment) { + return (MMMLayoutAlignment)alignment; +} + +static inline MMMLayoutAlignment MMMLayoutAlignmentFromVerticalAlignment(MMMLayoutVerticalAlignment alignment) { + return (MMMLayoutAlignment)alignment; +} + +// +// +// +@interface UILayoutGuide (MMMTemple) + +/// Convenience initializer setting the guide's identifier. +- (id)initWithIdentifier:(NSString *)identifier; + +/** + * Not yet activated constraints anchoring the given view within the receiver according to horizontal + * and vertical alignment flags. + */ +- (NSArray *)mmm_constraintsAligningView:(UIView *)view + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets NS_SWIFT_NAME(mmm_constraints(aligning:horizontally:vertically:insets:)); + +- (NSArray *)mmm_constraintsAligningGuide:(UILayoutGuide *)guide + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets NS_SWIFT_NAME(mmm_constraints(aligning:horizontally:vertically:insets:)); + +/** + * Not yet activated constraints implementing a common layout idiom used with text: + * - the given view is centered within the receiver, + * - certain minimum padding is ensured on the sides, + * - if `maxWidth > 0`, then the width of the view is limited to `maxWidth`, so it does not grow too wide e.g. on iPad. + */ +- (NSArray *)mmm_constraintsHorizontallyCenteringView:(UIView *)view + minPadding:(CGFloat)minPadding + maxWidth:(CGFloat)maxWidth NS_SWIFT_NAME(mmm_constraints(horizontallyCentering:minPadding:maxWidth:)); + +@end + +/** + * A few shorthands for UIView. + */ +@interface UIView (MMMTemple) + +/** A wrapper for the `center` and `bounds.size` properties similar to 'frame', but not taking the current transform into account. + * Handy when there is a transform applied to a view already, but we want to set its frame in normal state. */ +@property (nonatomic, setter=mmm_setRect:) CGRect mmm_rect; + +/** A wrapper for the `size` component of `bounds` property. */ +@property (nonatomic, setter=mmm_setSize:) CGSize mmm_size; + +/** A safer version of `safeAreaLayoutGuide` that attempts to avoid layout loops happening when a view using it + * is transformed in certain "inconvenient" way. (Apple Feedback ID: FB7609936.) */ +@property (nonatomic, readonly) UILayoutGuide *mmm_safeAreaLayoutGuide; + +/** Effective `safeAreaInsets` as seen by `mmm_safeAreaLayoutGuide`. */ +@property (nonatomic, readonly) UIEdgeInsets mmm_safeAreaInsets; + +/** + * Constraints anchoring the given view within the receiver according to horizontal and vertical alignment flags. + * Note that constrains are not added into the reciever automatically. + * It is recommended to use this method instead of the `mmm_addConstraintsForSubview:*` bunch. + */ +- (NSArray *)mmm_constraintsAligningView:(UIView *)subview + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets; + +- (NSArray *)mmm_constraintsAligningView:(UIView *)subview + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment DEPRECATED_ATTRIBUTE; + +- (NSArray *)mmm_constraintsAligningView:(UIView *)subview + vertically:(MMMLayoutVerticalAlignment)verticalAlignment DEPRECATED_ATTRIBUTE; + +- (NSArray *)mmm_constraintsAligningGuide:(UILayoutGuide *)guide + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets NS_SWIFT_NAME(mmm_constraints(aligning:horizontally:vertically:insets:)); + +/** + * Adds contraints anchoring the given view within the receiver according to horizontal and vertical alignment flags. + * (This is a shortcut for calling mmm_constraintsAligningView:horizontally:vertically:insets: and adding the contraints returned.) + */ +- (NSArray *)mmm_addConstraintsAligningView:(UIView *)subview + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets; + +- (NSArray *)mmm_addConstraintsAligningView:(UIView *)subview + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment; + +- (NSArray *)mmm_addConstraintsAligningView:(UIView *)subview + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment DEPRECATED_ATTRIBUTE; + +- (NSArray *)mmm_addConstraintsAligningView:(UIView *)subview + vertically:(MMMLayoutVerticalAlignment)verticalAlignment DEPRECATED_ATTRIBUTE; + +/** + * Not yet activated constraints implementing a common layout idiom used with text: + * - the given view is centered within the receiver, + * - certain minimum padding is ensured on the sides, + * - if `maxWidth > 0`, then the width of the view is limited to `maxWidth`, so it does not grow too wide e.g. on iPad. + */ +- (NSArray *)mmm_constraintsHorizontallyCenteringView:(UIView *)view + minPadding:(CGFloat)minPadding + maxWidth:(CGFloat)maxWidth NS_REFINED_FOR_SWIFT; + +/** A shortcut activating constraints returned by `mmm_constraintsHorizontallyCenteringView:minPadding:maxWidth:`. */ +- (void)mmm_addConstraintsHorizontallyCenteringView:(UIView *)view + minPadding:(CGFloat)minPadding + maxWidth:(CGFloat)maxWidth NS_REFINED_FOR_SWIFT; + +/** A shortcut activating constraints returned by `mmm_constraintsHorizontallyCenteringView:minPadding:maxWidth:` + * setting `maxWidth` to zero. */ +- (void)mmm_addConstraintsHorizontallyCenteringView:(UIView *)view + minPadding:(CGFloat)minPadding NS_SWIFT_UNAVAILABLE(""); + +#pragma mark - To be depcreated soon + +/** + * Adds constraints anchoring the given subview within the receiver according to horizontal and vertical alignment flags. + * The constraints are also returned, so the caller can remove them later, for example. + */ +- (NSArray *)mmm_addConstraintsForSubview:(UIView *)subview + horizontalAlignment:(UIControlContentHorizontalAlignment)horizontalAlignment + verticalAlignment:(UIControlContentVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets + DEPRECATED_ATTRIBUTE; + +- (NSArray *)mmm_addConstraintsForSubview:(UIView *)subview + horizontalAlignment:(UIControlContentHorizontalAlignment)horizontalAlignment + verticalAlignment:(UIControlContentVerticalAlignment)verticalAlignment + DEPRECATED_ATTRIBUTE; + +#pragma mark - + +/** + * Adds constraints and two hidden auxiliary views ensuring that the space between the top of the subview and + * topAttribute of topItem is in 'ratio' proportion to the space between the bottom of the subview + * and bottomAttribute of bottomItem. + * + * To be clear: + * ratio = (top space) / (bottom space) + * + * So you need to use 1 when you want the same size, not 0.5, for example. + * + * The given priority will be used for the constraints between the heights of the aux views. + */ +- (void)mmm_addVerticalSpaceRatioConstraintsForSubview:(UIView *)subview + topItem:(id)topItem topAttribute:(NSLayoutAttribute)topAttribute + bottomItem:(id)bottomItem bottomAttribute:(NSLayoutAttribute)bottomAttribute + ratio:(CGFloat)ratio + priority:(UILayoutPriority)priority; + +- (void)mmm_addVerticalSpaceRatioConstraintsForSubview:(UIView *)subview + topItem:(id)topItem topAttribute:(NSLayoutAttribute)topAttribute + bottomItem:(id)bottomItem bottomAttribute:(NSLayoutAttribute)bottomAttribute + ratio:(CGFloat)ratio; + +/** + * Adds constrains and a hidden auxiliary view ensuring that specified item / attribute vertically divides + * the subview in the specified ratio. + * Unlike the previous function the ratio here is given not as (top space / bottom space), but as + * (top space / (top space + bottom space)). Sorry for the confusion, deprecating this one for now. + */ +- (void)mmm_addVerticalSpaceRatioConstraintsForSubview:(UIView *)subview + item:(id)item attribute:(NSLayoutAttribute)attribute + ratio:(CGFloat)ratio DEPRECATED_ATTRIBUTE; + +/** @{ */ + +/** Shortcuts for compression resistance and hugging priorities. */ + +- (void)mmm_setVerticalCompressionResistance:(UILayoutPriority)priority; +- (void)mmm_setHorizontalCompressionResistance:(UILayoutPriority)priority; + +- (void)mmm_setVerticalHuggingPriority:(UILayoutPriority)priority; +- (void)mmm_setHorizontalHuggingPriority:(UILayoutPriority)priority; + +- (void)mmm_setVerticalCompressionResistance:(UILayoutPriority)compressionResistance hugging:(UILayoutPriority)hugging DEPRECATED_ATTRIBUTE; +- (void)mmm_setHorizontalCompressionResistance:(UILayoutPriority)compressionResistance hugging:(UILayoutPriority)hugging DEPRECATED_ATTRIBUTE; + +- (void)mmm_setCompressionResistanceHorizontal:(UILayoutPriority)horizontal + vertical:(UILayoutPriority)vertical NS_SWIFT_NAME(mmm_setCompressionResistance(horizontal:vertical:)); + +- (void)mmm_setHuggingHorizontal:(UILayoutPriority)horizontal + vertical:(UILayoutPriority)vertical NS_SWIFT_NAME(mmm_setHugging(horizontal:vertical:)); + +/** @} */ + +@end + +@interface NSLayoutConstraint (MMMTemple) + +/** + * Our wrapper over the corresponding method of NSLayoutConstraint extending the visual layout language a bit to support + * `safeAreaLayoutGuide` property introduced in iOS 11 and still be compatible with older versions of iOS. + * (See also `mmm_safeAreaLayoutGuide` in our extension of UIView.) + * + * To use it simply replace a reference to the superview edge "|" with a reference to a safe edge "<|". + * + * For example, if you have the following pre iOS 9 code: + * + * \code + * [NSLayoutConstraint activateConstraints:[NSLayoutConstraint + * constraintsWithVisualFormat:@"V:[_button]-(normalPadding)-|" + * options:0 metrics:metrics views:views + * ]]; + * \endcode + * + * And now you want to make sure that the button sits above the safe bottom margin on iPhone X, then do this: + * + * \code + * [NSLayoutConstraint activateConstraints:[NSLayoutConstraint + * mmm_constraintsWithVisualFormat:@"V:[_button]-(normalPadding)-<|" + * options:0 metrics:metrics views:views + * ]]; + * \endcode + * + * That's it. It'll anchor the button to the bottom of its superview on iOS 9 and 10, but anchor it to the bottom of + * its safeAreaLayoutGuide on iOS 11. + * + * Please note that using "|>" to pin to the top won't exclude the status bar on iOS 9 and 10. + */ ++ (NSArray *)mmm_constraintsWithVisualFormat:(NSString *)format + options:(NSLayoutFormatOptions)opts + metrics:(nullable NSDictionary *)metrics + views:(nullable NSDictionary *)views; + +/** A shortcut for `[NSLayoutConstraint activateConstraints:[NSLayoutConstraint mmm_constraintsWithVisualFormat:...`. */ ++ (void)mmm_activateConstraintsWithVisualFormat:(NSString *)format + options:(NSLayoutFormatOptions)opts + metrics:(nullable NSDictionary *)metrics + views:(nullable NSDictionary *)views; + +/** Missing counterparts for (de)activateConstraints, so constraint activation code looks the same for individual constraints. */ ++ (void)activateConstraint:(NSLayoutConstraint *)constraint; ++ (void)deactivateConstraint:(NSLayoutConstraint *)constraint; + +/** A missing convenience initializer including priority. */ ++ (instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 + relatedBy:(NSLayoutRelation)relation + toItem:(nullable id)view2 attribute:(NSLayoutAttribute)attr2 + multiplier:(CGFloat)multiplier constant:(CGFloat)c + priority:(UILayoutPriority)priority; + +/** A missing convenience initializer allowing to set identifier for this constraint. */ ++ (instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 + relatedBy:(NSLayoutRelation)relation + toItem:(nullable id)view2 attribute:(NSLayoutAttribute)attr2 + multiplier:(CGFloat)multiplier constant:(CGFloat)c + identifier:(NSString *)identifier; + +/** A missing convenience initializer allowing to set both priority and identifier for this constraint. */ ++ (instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 + relatedBy:(NSLayoutRelation)relation + toItem:(nullable id)view2 attribute:(NSLayoutAttribute)attr2 + multiplier:(CGFloat)multiplier constant:(CGFloat)c + priority:(UILayoutPriority)priority + identifier:(NSString *)identifier; + +/** A missing convenience initializer allowing to tag a bunch of visual constraints with the same identifier. */ ++ (NSArray<__kindof NSLayoutConstraint *> *)constraintsWithVisualFormat:(NSString *)format + options:(NSLayoutFormatOptions)opts + metrics:(nullable NSDictionary *)metrics + views:(nullable NSDictionary *)views + identifier:(NSString *)identifier DEPRECATED_ATTRIBUTE; + +@end + +/** + * A dictionary built from UIEdgeInsets suitable for AutoLayout metrics. + * The dictionary will have 4 values under the keys named "Top", "Left", "Bottom", "Right". + */ +extern NSDictionary *MMMDictionaryFromUIEdgeInsets(NSString *prefix, UIEdgeInsets insets); + + +/** + * A container which lays out its subviews in certain direction one after another using fixed spacing between them. + * It also aligns all the items along the layout line acccoring to the given alignment settings. + * Note that you must use setSubviews: method instead of feeding them one by one via `addSubview:`. + * This is kind of a `UIStackView` that we understand the internals of. + */ +@interface MMMStackContainer : UIView + +/** Sets subviews to be laid out. Previously set subviews will be removed from this container first. */ +- (void)setSubviews:(NSArray *)subviews; + +/** + * Insets define the padding around all the subviews. + * Alignment influences horizontal constraints added for the subviews. + * Spacing is the fixed distance to set between items. + */ +- (id)initWithDirection:(MMMLayoutDirection)direction + insets:(UIEdgeInsets)insets + alignment:(MMMLayoutAlignment)alignment + spacing:(CGFloat)spacing NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; + +@end + +/** + * Vertical version of MMMStackContainer. + */ +@interface MMMVerticalStackContainer : MMMStackContainer + +- (id)initWithInsets:(UIEdgeInsets)insets + alignment:(MMMLayoutHorizontalAlignment)alignment + spacing:(CGFloat)spacing NS_DESIGNATED_INITIALIZER; + +- (id)initWithDirection:(MMMLayoutDirection)direction + insets:(UIEdgeInsets)insets + alignment:(MMMLayoutAlignment)alignment + spacing:(CGFloat)spacing NS_UNAVAILABLE; + +@end + +/** + * Horizontal version of MMMStackContainer. + */ +@interface MMMHorizontalStackContainer : MMMStackContainer + +- (id)initWithInsets:(UIEdgeInsets)insets + alignment:(MMMLayoutVerticalAlignment)alignment + spacing:(CGFloat)spacing NS_DESIGNATED_INITIALIZER; + +- (id)initWithDirection:(MMMLayoutDirection)direction + insets:(UIEdgeInsets)insets + alignment:(MMMLayoutAlignment)alignment + spacing:(CGFloat)spacing NS_UNAVAILABLE; + +@end + +/** + * Wraps a view that uses Auto Layout into a manual layout view providing sizeThatFits: for the outside world. + * Can be handy with old APIs that do not fully support Auto Layout. + */ +@interface MMMAutoLayoutIsolator : UIView + +/** The view being wrapped. */ +@property (nonatomic, readonly) UIView *view; + +- (id)initWithView:(UIView *)view NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +@end + +/** + * Wraps a view padding it from all the sides. + */ +@interface MMMPaddedView : UIView + +/** The view being wrapped. */ +@property (nonatomic, readonly) UIView *view; + +@property (nonatomic, readonly) UIEdgeInsets insets; + +- (id)initWithView:(UIView *)view insets:(UIEdgeInsets)insets NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMLayout.m b/Sources/MMMCommonUIObjC/MMMLayout.m new file mode 100644 index 0000000..55a6c71 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMLayout.m @@ -0,0 +1,1367 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMLayout.h" + +@import MMMCommonCore; + +#import "MMMCommonUI.h" +#import + +// +// +// +@implementation MMMLayoutUtils + ++ (CGRect)rectWithSize:(CGSize)size anchor:(CGPoint)anchor withinRect:(CGRect)targetRect anchor:(CGPoint)targetAnchor { + return MMMPixelIntegralRect(CGRectMake( + targetRect.origin.x + targetRect.size.width * targetAnchor.x - size.width * anchor.x, + targetRect.origin.y + targetRect.size.height * targetAnchor.y - size.height * anchor.y, + size.width, + size.height + )); +} + ++ (CGRect)rectWithSize:(CGSize)size withinRect:(CGRect)targetRect anchor:(CGPoint)anchor { + return MMMPixelIntegralRect(CGRectMake( + targetRect.origin.x + (targetRect.size.width - size.width) * anchor.x, + targetRect.origin.y + (targetRect.size.height - size.height) * anchor.y, + size.width, + size.height + )); +} + ++ (CGRect)rectWithSize:(CGSize)size withinRect:(CGRect)targetRect contentMode:(UIViewContentMode)contentMode { + + switch (contentMode) { + + case UIViewContentModeScaleToFill: + // Not much sense using this routine with this mode, but well, maybe it's coming from the corresponding property of UIView here + return targetRect; + + case UIViewContentModeScaleAspectFit: + case UIViewContentModeScaleAspectFill: + { + double scaleX = targetRect.size.width / size.width; + double scaleY = targetRect.size.height / size.height; + double scale = (contentMode == UIViewContentModeScaleAspectFit) ? MIN(scaleX, scaleY) : MAX(scaleX, scaleY); + CGFloat resultWidth = size.width * scale; + CGFloat resultHeight = size.height * scale; + return MMMPixelIntegralRect( + CGRectMake( + targetRect.origin.x + (targetRect.size.width - resultWidth) * 0.5f, + targetRect.origin.y + (targetRect.size.height - resultHeight) * 0.5f, + resultWidth, + resultHeight + ) + ); + } + + case UIViewContentModeRedraw: + NSAssert(NO, @"UIViewContentModeRedraw does not make any sense for %s", sel_getName(_cmd)); + return targetRect; + + case UIViewContentModeCenter: + return [self rectWithSize:size withinRect:targetRect anchor:CGPointMake(0.5, 0.5)]; + + case UIViewContentModeTop: + return [self rectWithSize:size withinRect:targetRect anchor:CGPointMake(0.5, 0)]; + + case UIViewContentModeBottom: + return [self rectWithSize:size withinRect:targetRect anchor:CGPointMake(0.5, 1)]; + + case UIViewContentModeLeft: + return [self rectWithSize:size withinRect:targetRect anchor:CGPointMake(0, 0.5)]; + + case UIViewContentModeRight: + return [self rectWithSize:size withinRect:targetRect anchor:CGPointMake(1, 0.5)]; + + case UIViewContentModeTopLeft: + return [self rectWithSize:size withinRect:targetRect anchor:CGPointMake(0, 0)]; + + case UIViewContentModeTopRight: + return [self rectWithSize:size withinRect:targetRect anchor:CGPointMake(1, 0)]; + + case UIViewContentModeBottomLeft: + return [self rectWithSize:size withinRect:targetRect anchor:CGPointMake(0, 1)]; + + case UIViewContentModeBottomRight: + return [self rectWithSize:size withinRect:targetRect anchor:CGPointMake(1, 1)]; + } +} + ++ (CGRect)rectWithSize:(CGSize)size atPoint:(CGPoint)point anchor:(CGPoint)anchor { + return MMMPixelIntegralRect(CGRectMake( + point.x - size.width * anchor.x, + point.y - size.height * anchor.y, + size.width, + size.height + )); +} + ++ (CGRect)rectWithSize:(CGSize)size center:(CGPoint)center { + return [self rectWithSize:size atPoint:center anchor:CGPointMake(.5f, .5f)]; +} + +@end + +CGFloat const MMMGolden = 1.47093999 * 1.10; // 110% adjusted. +CGFloat const MMMInverseGolden = 1 / MMMGolden; + +// +// +// +@implementation MMMSpacerView + +- (id)init { + + if (self = [super initWithFrame:CGRectZero]) { + + self.translatesAutoresizingMaskIntoConstraints = NO; + + self.opaque = NO; + self.backgroundColor = [UIColor clearColor]; + + // It's not visible anyway, but let's further hide it just in case + self.hidden = YES; + + // TODO: these make no sense as we don't have intrinsic content + [self setContentHuggingPriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal]; + [self setContentHuggingPriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisVertical]; + [self setContentCompressionResistancePriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal]; + [self setContentCompressionResistancePriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisVertical]; + } + + return self; +} + +- (CGSize)intrinsicContentSize { + return CGSizeMake(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric); +} + +@end + +// +// +// + +@implementation MMMContainerView + +- (id)init { + + if (self = [super initWithFrame:CGRectZero]) { + self.translatesAutoresizingMaskIntoConstraints = NO; + } + + return self; +} + +@end + +// +// +// + +#pragma mark - Auto Layout helpers shared between UIView and UILayoutGuide + +static inline BOOL MMMLayoutUtilsIsKindOfCenterAlignment(MMMLayoutAlignment alignment) { + return alignment == MMMLayoutAlignmentGolden || alignment == MMMLayoutAlignmentCenter; +} + +static CGFloat MMMLayoutUtilsMultiplierForAlignment(MMMLayoutAlignment alignment) { + switch (alignment) { + case MMMLayoutAlignmentGolden: + return MMMCenterMultiplierForRatio(MMMInverseGolden); + case MMMLayoutAlignmentCenter: + return 1; + default: + NSCAssert(NO, @""); + return 1; + } +} + +// This is to be reused with views and guides. +static NSArray * _MMMConstraintsAligning( + id viewOrGuide, + id inViewOrGuide, + MMMLayoutHorizontalAlignment horizontalAlignment, + MMMLayoutVerticalAlignment verticalAlignment, + UIEdgeInsets insets +) { + + // Renaming to keep the code below intact. + id view = viewOrGuide; + id self = inViewOrGuide; + + NSMutableArray *result = [[NSMutableArray alloc] init]; + + // + // Horizontal constraints. + // + if (horizontalAlignment == MMMLayoutHorizontalAlignmentLeft) { + + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeLeft + relatedBy:NSLayoutRelationEqual + toItem:inViewOrGuide attribute:NSLayoutAttributeLeft + multiplier:1 constant:insets.left + ]]; + + // The right edge of the subview should stay within this view. + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeRight + relatedBy:NSLayoutRelationLessThanOrEqual + toItem:self attribute:NSLayoutAttributeRight + multiplier:1 constant:-insets.right + ]]; + + } else if (MMMLayoutUtilsIsKindOfCenterAlignment(MMMLayoutAlignmentFromHorizontalAlignment(horizontalAlignment))) { + + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeCenterX + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeCenterX + multiplier:MMMLayoutUtilsMultiplierForAlignment(MMMLayoutAlignmentFromHorizontalAlignment(horizontalAlignment)) + constant:(insets.left - insets.right) * .5 // TODO: should not we use a multiplier as well? + ]]; + + // It should be centered, but in addition the edges should stay within the subview. + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeLeft + relatedBy:NSLayoutRelationGreaterThanOrEqual + toItem:self attribute:NSLayoutAttributeLeft + multiplier:1 constant:insets.left + ]]; + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeRight + relatedBy:NSLayoutRelationLessThanOrEqual + toItem:self attribute:NSLayoutAttributeRight + multiplier:1 constant:-insets.right + ]]; + + } else if (horizontalAlignment == MMMLayoutHorizontalAlignmentRight) { + + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeRight + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeRight + multiplier:1 constant:-insets.right + ]]; + + // Again, it's not only the right edge should be pinned, but the left edge must be within this view as well. + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeLeft + relatedBy:NSLayoutRelationGreaterThanOrEqual + toItem:self attribute:NSLayoutAttributeLeft + multiplier:1 constant:insets.left + ]]; + + } else if (horizontalAlignment == MMMLayoutHorizontalAlignmentFill) { + + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeLeft + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeLeft + multiplier:1 constant:insets.left + ]]; + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeRight + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeRight + multiplier:1 constant:-insets.right + ]]; + + } else if (horizontalAlignment == MMMLayoutHorizontalAlignmentNone ) { + + // Don't need to add anything. + + } else { + NSCAssert(NO, @""); + } + + // + // Vertical constraints. + // + if (verticalAlignment == MMMLayoutVerticalAlignmentTop) { + + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeTop + multiplier:1 constant:insets.top + ]]; + + // Again, we pin the top, but ensuring the bottom is within this view. + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationLessThanOrEqual + toItem:self attribute:NSLayoutAttributeBottom + multiplier:1 constant:-insets.bottom + ]]; + + } else if (MMMLayoutUtilsIsKindOfCenterAlignment(MMMLayoutAlignmentFromVerticalAlignment(verticalAlignment))) { + + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeCenterY + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeCenterY + multiplier:MMMLayoutUtilsMultiplierForAlignment(MMMLayoutAlignmentFromVerticalAlignment(verticalAlignment)) + constant:(insets.top - insets.bottom) * .5 // TODO: should not we use a multiplier as well? + ]]; + + // Again, ensuring the subview stays within this view vertically. + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationGreaterThanOrEqual + toItem:self attribute:NSLayoutAttributeTop + multiplier:1 constant:insets.top + ]]; + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationLessThanOrEqual + toItem:self attribute:NSLayoutAttributeBottom + multiplier:1 constant:-insets.bottom + ]]; + + } else if (verticalAlignment == MMMLayoutVerticalAlignmentBottom) { + + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeBottom + multiplier:1 constant:-insets.bottom + ]]; + + // The top can be anywhere, but should not stick out. + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationGreaterThanOrEqual + toItem:self attribute:NSLayoutAttributeTop + multiplier:1 constant:insets.top + ]]; + + } else if (verticalAlignment == MMMLayoutVerticalAlignmentFill) { + + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeTop + multiplier:1 constant:insets.top + ]]; + + [result addObject:[NSLayoutConstraint + constraintWithItem:view attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self attribute:NSLayoutAttributeBottom + multiplier:1 constant:-insets.bottom + ]]; + + } else if (verticalAlignment == MMMLayoutVerticalAlignmentNone) { + + // Don't need to add anything. + + } else { + NSCAssert(NO, @""); + } + + return result; +} + +static NSArray * _MMMConstraintsHorizontallyCentering( + id viewOrGuide, + id inViewOrGuide, + CGFloat minPadding, + CGFloat maxWidth +) { + + NSMutableArray *result = [[NSMutableArray alloc] init]; + + NSDictionary *views = @{ @"view" : viewOrGuide }; + NSDictionary *metrics = @{ @"minPadding" : @(minPadding) }; + + [result addObjectsFromArray:[NSLayoutConstraint + constraintsWithVisualFormat:@"H:|-(>=minPadding,minPadding@249)-[view]-(>=minPadding,minPadding@249)-|" + options:0 metrics:metrics views:views + identifier:@"MMM-Text-SidePaddings" + ]]; + + [result addObject:[NSLayoutConstraint + constraintWithItem:viewOrGuide attribute:NSLayoutAttributeCenterX + relatedBy:NSLayoutRelationEqual + toItem:inViewOrGuide attribute:NSLayoutAttributeCenterX + multiplier:1 constant:0 + identifier:@"MMM-Text-CenterX" + ]]; + + if (maxWidth > 0) { + [result addObject:[NSLayoutConstraint + constraintWithItem:viewOrGuide attribute:NSLayoutAttributeWidth + relatedBy:NSLayoutRelationLessThanOrEqual + toItem:nil attribute:NSLayoutAttributeNotAnAttribute + multiplier:1 constant:maxWidth + identifier:@"MMM-Text-MaxWidth" + ]]; + } + + return result; +} + + +// +// +// +@implementation UILayoutGuide (MMMTempleMMMCommonUI) + +- (id)initWithIdentifier:(NSString *)identifier { + if (self = [super init]) { + self.identifier = identifier; + } + return self; +} + +- (NSArray *)mmm_constraintsAligningView:(UIView *)view + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets +{ + return _MMMConstraintsAligning(view, self, horizontalAlignment, verticalAlignment, insets); +} + +- (NSArray *)mmm_constraintsAligningGuide:(UILayoutGuide *)guide + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets +{ + return _MMMConstraintsAligning(guide, self, horizontalAlignment, verticalAlignment, insets); +} + +- (NSArray *)mmm_constraintsHorizontallyCenteringView:(UIView *)view + minPadding:(CGFloat)minPadding + maxWidth:(CGFloat)maxWidth +{ + return _MMMConstraintsHorizontallyCentering(view, self, minPadding, maxWidth); +} + +@end + + +#pragma mark - MMMSafeAreaLayoutGuide + +/// A struct-like object to store some things related to mmm_safeAreaLayoutGuide on UIView. +@interface MMMSafeAreaLayoutGuideState : NSObject + +@property (nonatomic, readwrite, weak) UILayoutGuide *layoutGuide; +@property (nonatomic, readwrite, weak) NSLayoutConstraint *top; +@property (nonatomic, readwrite, weak) NSLayoutConstraint *left; +@property (nonatomic, readwrite, weak) NSLayoutConstraint *bottom; +@property (nonatomic, readwrite, weak) NSLayoutConstraint *right; + +@end + +@implementation MMMSafeAreaLayoutGuideState +@end + +// +// +// +@implementation UIView (MMMTempleMMMCommonUI) + +static inline NSLayoutConstraint *_MMMSafeAreaLayoutGuideConstraint(UIView *view, UILayoutGuide *guide, NSLayoutAttribute attr) { + NSLayoutConstraint *result = [NSLayoutConstraint + constraintWithItem:guide attribute:attr + relatedBy:NSLayoutRelationEqual + toItem:view attribute:attr + multiplier:1 constant:0 + ]; + result.identifier = @"mmm_safeAreaLayoutGuide"; + result.active = YES; + return result; +} + +- (MMMSafeAreaLayoutGuideState *)_mmm_safeAreaLayoutGuideStateCreateIfNeeded:(BOOL)createIfNeeded { + + static const char * const key = "MMMSafeAreaLayoutGuideState"; + MMMSafeAreaLayoutGuideState *result = objc_getAssociatedObject(self, key); + + if (!result && createIfNeeded) { + + result = [[MMMSafeAreaLayoutGuideState alloc] init]; + objc_setAssociatedObject(self, key, result, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + UILayoutGuide *layoutGuide = [[UILayoutGuide alloc] init]; + layoutGuide.identifier = @"mmm_safeAreaLayoutGuide"; + [self addLayoutGuide:layoutGuide]; + result.layoutGuide = layoutGuide; + + result.top = _MMMSafeAreaLayoutGuideConstraint(self, layoutGuide, NSLayoutAttributeTop); + result.left = _MMMSafeAreaLayoutGuideConstraint(self, layoutGuide, NSLayoutAttributeLeft); + result.bottom = _MMMSafeAreaLayoutGuideConstraint(self, layoutGuide, NSLayoutAttributeBottom); + result.right = _MMMSafeAreaLayoutGuideConstraint(self, layoutGuide, NSLayoutAttributeRight); + + // We need to be able to get `safeAreaInsetsDidChange`. + static dispatch_once_t swizzleToken; + dispatch_once(&swizzleToken, ^{ + Method oldMethod = class_getInstanceMethod(self.class, @selector(safeAreaInsetsDidChange)); + Method newMethod = class_getInstanceMethod(self.class, @selector(_mmm_safeAreaInsetsDidChange)); + method_exchangeImplementations(oldMethod, newMethod); + }); + } + + return result; +} + +- (UILayoutGuide *)mmm_safeAreaLayoutGuide { + return [self _mmm_safeAreaLayoutGuideStateCreateIfNeeded:YES].layoutGuide; +} + +static inline void MMMAdjustConstant(NSLayoutConstraint *c, CGFloat value) { + CGFloat v = MMMPixelRound(value); + // It has to be slightly larger than 1 / MMMPixelScale(), as single pixel variations will cause the oscillation. + // However 2 / MMMPixelScale() would make it more coarse than necessary. + const CGFloat eps = 1.5 / MMMPixelScale(); + if (fabs(c.constant - v) > eps) { + c.constant = v; + } +} + +- (void)_mmm_safeAreaInsetsDidChange { + + MMMSafeAreaLayoutGuideState *state = [self _mmm_safeAreaLayoutGuideStateCreateIfNeeded:NO]; + if (state) { + + UIEdgeInsets insets = self.safeAreaInsets; + + MMMAdjustConstant(state.top, insets.top); + MMMAdjustConstant(state.left, insets.left); + MMMAdjustConstant(state.bottom, -insets.bottom); + MMMAdjustConstant(state.right, -insets.right); + + //~ NSLog(@"_mmm_safeAreaInsetsDidChange: %@ vs %@", NSStringFromUIEdgeInsets(self.safeAreaInsets), NSStringFromUIEdgeInsets(self.mmm_safeAreaInsets)); + } + + [self _mmm_safeAreaInsetsDidChange]; +} + +- (UIEdgeInsets)mmm_safeAreaInsets { + MMMSafeAreaLayoutGuideState *state = [self _mmm_safeAreaLayoutGuideStateCreateIfNeeded:NO]; + if (state) { + return UIEdgeInsetsMake(state.top.constant, state.left.constant, -state.bottom.constant, -state.right.constant); + } else { + return self.safeAreaInsets; + } +} + +- (CGRect)mmm_rect { + + CGPoint anchorPoint = self.layer.anchorPoint; + CGPoint center = self.center; + CGRect bounds = self.bounds; + return CGRectMake( + center.x - anchorPoint.x * bounds.size.width, + center.y - anchorPoint.y * bounds.size.height, + bounds.size.width, + bounds.size.height + ); +} + +- (void)mmm_setRect:(CGRect)frame { + + CGPoint anchorPoint = self.layer.anchorPoint; + self.center = CGPointMake( + frame.origin.x + anchorPoint.x * frame.size.width, + frame.origin.y + anchorPoint.y * frame.size.height + ); + + CGRect bounds = self.bounds; + self.bounds = CGRectMake(bounds.origin.x, bounds.origin.y, frame.size.width, frame.size.height); +} + +#pragma mark - + +- (CGSize)mmm_size { + return self.bounds.size; +} + +- (void)mmm_setSize:(CGSize)size { + CGRect b = self.bounds; + b.size = size; + self.bounds = b; +} + +#pragma mark - + +- (NSArray *)mmm_constraintsAligningView:(UIView *)view + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets +{ + return _MMMConstraintsAligning(view, self, horizontalAlignment, verticalAlignment, insets); +} + +- (NSArray *)mmm_constraintsAligningView:(UIView *)subview + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment +{ + return [self mmm_constraintsAligningView:subview horizontally:horizontalAlignment vertically:MMMLayoutVerticalAlignmentNone insets:UIEdgeInsetsZero]; +} + +- (NSArray *)mmm_constraintsAligningView:(UIView *)subview + vertically:(MMMLayoutVerticalAlignment)verticalAlignment +{ + return [self mmm_constraintsAligningView:subview horizontally:MMMLayoutHorizontalAlignmentNone vertically:verticalAlignment insets:UIEdgeInsetsZero]; +} + +- (NSArray *)mmm_constraintsAligningGuide:(UILayoutGuide *)guide + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets +{ + return _MMMConstraintsAligning(guide, self, horizontalAlignment, verticalAlignment, insets); +} + +#pragma mark - + +- (NSArray *)mmm_addConstraintsAligningView:(UIView *)view + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets +{ + NSArray *result = [self mmm_constraintsAligningView:view horizontally:horizontalAlignment vertically:verticalAlignment insets:insets]; + [NSLayoutConstraint activateConstraints:result]; + return result; +} + +- (NSArray *)mmm_addConstraintsAligningView:(UIView *)view + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment + vertically:(MMMLayoutVerticalAlignment)verticalAlignment +{ + return [self + mmm_addConstraintsAligningView:view + horizontally:horizontalAlignment + vertically:verticalAlignment + insets:UIEdgeInsetsZero + ]; +} + +- (NSArray *)mmm_addConstraintsAligningView:(UIView *)subview + horizontally:(MMMLayoutHorizontalAlignment)horizontalAlignment +{ + return [self + mmm_addConstraintsAligningView:subview + horizontally:horizontalAlignment + vertically:MMMLayoutVerticalAlignmentNone + insets:UIEdgeInsetsZero + ]; +} + +- (NSArray *)mmm_addConstraintsAligningView:(UIView *)subview + vertically:(MMMLayoutVerticalAlignment)verticalAlignment +{ + return [self + mmm_addConstraintsAligningView:subview + horizontally:MMMLayoutHorizontalAlignmentNone + vertically:verticalAlignment + insets:UIEdgeInsetsZero + ]; +} + +#pragma mark - + +- (NSArray *)mmm_constraintsHorizontallyCenteringView:(UIView *)view + minPadding:(CGFloat)minPadding + maxWidth:(CGFloat)maxWidth +{ + return _MMMConstraintsHorizontallyCentering(view, self, minPadding, maxWidth); +} + +- (void)mmm_addConstraintsHorizontallyCenteringView:(UIView *)view + minPadding:(CGFloat)minPadding + maxWidth:(CGFloat)maxWidth +{ + [NSLayoutConstraint activateConstraints:[self + mmm_constraintsHorizontallyCenteringView:view + minPadding:minPadding + maxWidth:maxWidth + ]]; +} + +- (void)mmm_addConstraintsHorizontallyCenteringView:(UIView *)view + minPadding:(CGFloat)minPadding +{ + [self mmm_addConstraintsHorizontallyCenteringView:view minPadding:minPadding maxWidth:0]; +} + +#pragma mark - Old versions of the helper functions + +- (MMMLayoutHorizontalAlignment)horizontalAlignmentFromContentAlignment:(UIControlContentHorizontalAlignment)alignment { + switch (alignment) { + case UIControlContentHorizontalAlignmentLeft: + return MMMLayoutHorizontalAlignmentLeft; + case UIControlContentHorizontalAlignmentCenter: + return MMMLayoutHorizontalAlignmentCenter; + case UIControlContentHorizontalAlignmentRight: + return MMMLayoutHorizontalAlignmentRight; + case UIControlContentHorizontalAlignmentFill: + return MMMLayoutHorizontalAlignmentFill; + default: + NSAssert(NO, @"The alignment flag is not supported: %ld", (long)alignment); + return MMMLayoutHorizontalAlignmentFill; + } +} + +- (MMMLayoutVerticalAlignment)verticalAlignmentFromContentAlignment:(UIControlContentVerticalAlignment)alignment { + switch (alignment) { + case UIControlContentVerticalAlignmentTop: + return MMMLayoutVerticalAlignmentTop; + case UIControlContentVerticalAlignmentCenter: + return MMMLayoutVerticalAlignmentCenter; + case UIControlContentVerticalAlignmentBottom: + return MMMLayoutVerticalAlignmentBottom; + case UIControlContentVerticalAlignmentFill: + return MMMLayoutVerticalAlignmentFill; + } +} + +- (NSArray *)mmm_addConstraintsForSubview:(UIView *)subview + horizontalAlignment:(UIControlContentHorizontalAlignment)horizontalAlignment + verticalAlignment:(UIControlContentVerticalAlignment)verticalAlignment + insets:(UIEdgeInsets)insets +{ + NSArray *result = [self + mmm_addConstraintsAligningView:subview + horizontally:[self horizontalAlignmentFromContentAlignment:horizontalAlignment] + vertically:[self verticalAlignmentFromContentAlignment:verticalAlignment] + insets:insets + ]; + return result; +} + +- (NSArray *)mmm_addConstraintsForSubview:(UIView *)subview + horizontalAlignment:(UIControlContentHorizontalAlignment)horizontalAlignment + verticalAlignment:(UIControlContentVerticalAlignment)verticalAlignment +{ + return [self mmm_addConstraintsForSubview:subview horizontalAlignment:horizontalAlignment verticalAlignment:verticalAlignment insets:UIEdgeInsetsZero]; +} + +#pragma mark - + +- (void)mmm_addVerticalSpaceRatioConstraintsForSubview:(UIView *)subview + topItem:(id)topItem topAttribute:(NSLayoutAttribute)topAttribute + bottomItem:(id)bottomItem bottomAttribute:(NSLayoutAttribute)bottomAttribute + ratio:(CGFloat)ratio +{ + [self + mmm_addVerticalSpaceRatioConstraintsForSubview:subview + topItem:topItem topAttribute:topAttribute + bottomItem:bottomItem bottomAttribute:bottomAttribute + ratio:ratio priority:UILayoutPriorityDefaultHigh + ]; +} + +- (void)mmm_addVerticalSpaceRatioConstraintsForSubview:(UIView *)subview + topItem:(id)topItem topAttribute:(NSLayoutAttribute)topAttribute + bottomItem:(id)bottomItem bottomAttribute:(NSLayoutAttribute)bottomAttribute + ratio:(CGFloat)ratio + priority:(UILayoutPriority)priority +{ + NSAssert( + topAttribute == NSLayoutAttributeBottom || topAttribute == NSLayoutAttributeCenterY || topAttribute == NSLayoutAttributeTop || bottomAttribute == NSLayoutAttributeBottom || bottomAttribute == NSLayoutAttributeCenterY || bottomAttribute == NSLayoutAttributeTop, + @"We expect vertical attributes here" + ); + + // We need these auxiliary views because before iOS 9 we can define constraints for views only. + MMMSpacerView *topSpacer = [[MMMSpacerView alloc] init]; + [self addSubview:topSpacer]; + MMMSpacerView *bottomSpacer = [[MMMSpacerView alloc] init]; + [self addSubview:bottomSpacer]; + + // So the height of the spacers should be in the required proportion. + [self addConstraint:[NSLayoutConstraint + constraintWithItem:topSpacer attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:bottomSpacer attribute:NSLayoutAttributeHeight + multiplier:ratio constant:0 + priority:priority + ]]; + + // Let's anchor the aux views to the top and bottom items. + [self addConstraint:[NSLayoutConstraint + constraintWithItem:topSpacer attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:topItem attribute:topAttribute + multiplier:1 constant:0 + ]]; + [self addConstraint:[NSLayoutConstraint + constraintWithItem:bottomSpacer attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:bottomItem attribute:bottomAttribute + multiplier:1 constant:0 + ]]; + + // And let's anchor the subview to the spacers. + [self addConstraint:[NSLayoutConstraint + constraintWithItem:subview attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:topSpacer attribute:NSLayoutAttributeBottom + multiplier:1 constant:0 + ]]; + [self addConstraint:[NSLayoutConstraint + constraintWithItem:subview attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:bottomSpacer attribute:NSLayoutAttributeTop + multiplier:1 constant:0 + ]]; +} + +- (void)mmm_addVerticalSpaceRatioConstraintsForSubview:(UIView *)subview + item:(id)item attribute:(NSLayoutAttribute)attribute + ratio:(CGFloat)ratio +{ + MMMSpacerView *topSpacer = [[MMMSpacerView alloc] init]; + [self addSubview:topSpacer]; + + [self addConstraint:[NSLayoutConstraint + constraintWithItem:topSpacer attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:subview attribute:NSLayoutAttributeHeight + multiplier:ratio constant:0 + ]]; + [self addConstraint:[NSLayoutConstraint + constraintWithItem:topSpacer attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:item attribute:attribute + multiplier:1 constant:0 + ]]; + + [self addConstraint:[NSLayoutConstraint + constraintWithItem:topSpacer attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:subview attribute:NSLayoutAttributeTop + multiplier:1 constant:0 + ]]; +} + +- (void)mmm_setVerticalCompressionResistance:(UILayoutPriority)priority { + [self setContentCompressionResistancePriority:priority forAxis:UILayoutConstraintAxisVertical]; +} + +- (void)mmm_setHorizontalCompressionResistance:(UILayoutPriority)priority { + [self setContentCompressionResistancePriority:priority forAxis:UILayoutConstraintAxisHorizontal]; +} + +- (void)mmm_setVerticalHuggingPriority:(UILayoutPriority)priority { + [self setContentHuggingPriority:priority forAxis:UILayoutConstraintAxisVertical]; +} + +- (void)mmm_setHorizontalHuggingPriority:(UILayoutPriority)priority { + [self setContentHuggingPriority:priority forAxis:UILayoutConstraintAxisHorizontal]; +} + +- (void)mmm_setVerticalCompressionResistance:(UILayoutPriority)compressionResistance hugging:(UILayoutPriority)hugging { + [self setContentCompressionResistancePriority:compressionResistance forAxis:UILayoutConstraintAxisVertical]; + [self setContentHuggingPriority:hugging forAxis:UILayoutConstraintAxisVertical]; +} + +- (void)mmm_setHorizontalCompressionResistance:(UILayoutPriority)compressionResistance hugging:(UILayoutPriority)hugging { + [self setContentCompressionResistancePriority:compressionResistance forAxis:UILayoutConstraintAxisHorizontal]; + [self setContentHuggingPriority:hugging forAxis:UILayoutConstraintAxisHorizontal]; +} + +- (void)mmm_setCompressionResistanceHorizontal:(UILayoutPriority)horizontal vertical:(UILayoutPriority)vertical { + [self setContentCompressionResistancePriority:horizontal forAxis:UILayoutConstraintAxisHorizontal]; + [self setContentCompressionResistancePriority:vertical forAxis:UILayoutConstraintAxisVertical]; +} + +- (void)mmm_setHuggingHorizontal:(UILayoutPriority)horizontal vertical:(UILayoutPriority)vertical { + [self setContentHuggingPriority:horizontal forAxis:UILayoutConstraintAxisHorizontal]; + [self setContentHuggingPriority:vertical forAxis:UILayoutConstraintAxisVertical]; +} + +@end + +// +// +// +@implementation NSLayoutConstraint (MMMTempleMMMCommonUI) + +static inline NSLayoutAttribute _MMMOppositeAttribute(NSLayoutAttribute a) { + switch (a) { + case NSLayoutAttributeLeft: + return NSLayoutAttributeRight; + case NSLayoutAttributeRight: + return NSLayoutAttributeLeft; + case NSLayoutAttributeLeading: + return NSLayoutAttributeTrailing; + case NSLayoutAttributeTrailing: + return NSLayoutAttributeLeading; + case NSLayoutAttributeTop: + return NSLayoutAttributeBottom; + case NSLayoutAttributeBottom: + return NSLayoutAttributeTop; + // These two are special cases, we see them when align all X or Y flags are used. + case NSLayoutAttributeCenterY: + return NSLayoutAttributeCenterY; + case NSLayoutAttributeCenterX: + return NSLayoutAttributeCenterX; + // Nothing more. + default: + NSCAssert(NO, @"We don't expect other attributes here"); + return a; + } +} + +static inline UIView *_MMMSuperviewOrOwningView(id viewOrGuide) { + if ([viewOrGuide isKindOfClass:[UILayoutGuide class]]) { + return [(UILayoutGuide *)viewOrGuide owningView]; + } else if ([viewOrGuide isKindOfClass:[UIView class]]) { + return [(UIView *)viewOrGuide superview]; + } else { + NSCAssert(NO, @"Expected a view or a layout guide, got %@", NSStringFromClass(viewOrGuide)); + return nil; + } +} + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wpartial-availability" + ++(NSArray *)mmm_constraintsWithVisualFormat:(NSString *)format + options:(NSLayoutFormatOptions)opts + metrics:(NSDictionary *)metrics + views:(NSDictionary *)views +{ + if ([format rangeOfString:@"<|"].location == NSNotFound && [format rangeOfString:@"|>"].location == NSNotFound) { + // No traces of our special symbol, so do nothing special. + return [self constraintsWithVisualFormat:format options:opts metrics:metrics views:views]; + } + + if (![UIView instancesRespondToSelector:@selector(safeAreaLayoutGuide)]) { + // Before iOS 11 simply use the edges of the corresponding superview. + NSString *actualFormat = [format stringByReplacingOccurrencesOfString:@"<|" withString:@"|"]; + actualFormat = [actualFormat stringByReplacingOccurrencesOfString:@"|>" withString:@"|"]; + return [NSLayoutConstraint constraintsWithVisualFormat:actualFormat options:opts metrics:metrics views:views]; + } + + // + // OK, iOS 11+ time. + // For simplicity we replace our special symbols with a reference to a stub view, feed the updated format string + // to the system, and then replace every reference to our stub view with a corresponding reference to safeAreaLayoutGuide. + // + + UIView *stub = [[UIView alloc] init]; + static NSString * const stubKey = @"__MMMLayoutStub"; + NSString *stubKeyRef = [NSString stringWithFormat:@"[%@]", stubKey]; + NSDictionary *extendedViews = [@{ stubKey : stub } mmm_extendedWithDictionary:views]; + + NSString *actualFormat = [format stringByReplacingOccurrencesOfString:@"<|" withString:stubKeyRef]; + actualFormat = [actualFormat stringByReplacingOccurrencesOfString:@"|>" withString:stubKeyRef]; + + NSArray *constraints = [NSLayoutConstraint constraintsWithVisualFormat:actualFormat options:opts metrics:metrics views:extendedViews]; + + NSMutableArray *processedConstraints = [[NSMutableArray alloc] init]; + for (NSLayoutConstraint *c in constraints) { + UIView *firstView = c.firstItem; + UIView *secondView = c.secondItem; + NSLayoutConstraint *processed; + if (firstView == stub) { + processed = [self + constraintWithItem:_MMMSuperviewOrOwningView(secondView).mmm_safeAreaLayoutGuide attribute:_MMMOppositeAttribute(c.firstAttribute) + relatedBy:c.relation + toItem:secondView attribute:c.secondAttribute + multiplier:c.multiplier constant:c.constant + priority:c.priority + identifier:@"mmm_constraintsWithVisualFormat-first" + ]; + } else if (secondView == stub) { + processed = [self + constraintWithItem:firstView attribute:c.firstAttribute + relatedBy:c.relation + toItem:_MMMSuperviewOrOwningView(firstView).mmm_safeAreaLayoutGuide attribute:_MMMOppositeAttribute(c.secondAttribute) + multiplier:c.multiplier constant:c.constant + priority:c.priority + identifier:@"mmm_constraintsWithVisualFormat-second" + ]; + } else { + processed = c; + } + [processedConstraints addObject:processed]; + } + + return processedConstraints; +} + +#pragma clang diagnostic pop + ++ (void)mmm_activateConstraintsWithVisualFormat:(NSString *)format + options:(NSLayoutFormatOptions)opts + metrics:(NSDictionary *)metrics + views:(NSDictionary *)views +{ + [self activateConstraints:[self mmm_constraintsWithVisualFormat:format options:opts metrics:metrics views:views]]; +} + ++ (void)activateConstraint:(NSLayoutConstraint *)constraint { + [constraint setActive:YES]; +} + ++ (void)deactivateConstraint:(NSLayoutConstraint *)constraint { + [constraint setActive:NO]; +} + ++ (instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 + relatedBy:(NSLayoutRelation)relation + toItem:(id)view2 attribute:(NSLayoutAttribute)attr2 + multiplier:(CGFloat)multiplier constant:(CGFloat)c + priority:(UILayoutPriority)priority +{ + NSLayoutConstraint *result = [NSLayoutConstraint constraintWithItem:view1 attribute:attr1 relatedBy:relation toItem:view2 attribute:attr2 multiplier:multiplier constant:c]; + result.priority = priority; + return result; +} + ++ (instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 + relatedBy:(NSLayoutRelation)relation + toItem:(id)view2 attribute:(NSLayoutAttribute)attr2 + multiplier:(CGFloat)multiplier constant:(CGFloat)c + identifier:(NSString *)identifier +{ + NSLayoutConstraint *result = [NSLayoutConstraint constraintWithItem:view1 attribute:attr1 relatedBy:relation toItem:view2 attribute:attr2 multiplier:multiplier constant:c]; + result.identifier = identifier; + return result; +} + ++ (instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 + relatedBy:(NSLayoutRelation)relation + toItem:(id)view2 attribute:(NSLayoutAttribute)attr2 + multiplier:(CGFloat)multiplier constant:(CGFloat)c + priority:(UILayoutPriority)priority + identifier:(NSString *)identifier +{ + NSLayoutConstraint *result = [NSLayoutConstraint constraintWithItem:view1 attribute:attr1 relatedBy:relation toItem:view2 attribute:attr2 multiplier:multiplier constant:c priority:priority]; + result.identifier = identifier; + return result; +} + ++ (NSArray<__kindof NSLayoutConstraint *> *)constraintsWithVisualFormat:(NSString *)format + options:(NSLayoutFormatOptions)opts + metrics:(NSDictionary *)metrics + views:(NSDictionary *)views + identifier:(NSString *)identifier + { + NSArray *result = [self constraintsWithVisualFormat:format options:opts metrics:metrics views:views]; + for (NSLayoutConstraint *c in result) { + c.identifier = identifier; + } + return result; +} + +@end + +NSDictionary *MMMDictionaryFromUIEdgeInsets(NSString *prefix, UIEdgeInsets insets) { + NSCAssert(prefix != nil, @""); + return @{ + [prefix stringByAppendingString:@"Top"] : @(insets.top), + [prefix stringByAppendingString:@"Left"] : @(insets.left), + [prefix stringByAppendingString:@"Bottom"] : @(insets.bottom), + [prefix stringByAppendingString:@"Right"] : @(insets.right) + }; +} + +// +// +// +@implementation MMMStackContainer { + UIEdgeInsets _insets; + MMMLayoutAlignment _alignment; + MMMLayoutDirection _direction; + CGFloat _spacing; + NSMutableArray *_managedSubviews; +} + +- (id)initWithDirection:(MMMLayoutDirection)direction + insets:(UIEdgeInsets)insets + alignment:(MMMLayoutAlignment)alignment + spacing:(CGFloat)spacing +{ + if (self = [super initWithFrame:CGRectZero]) { + + self.translatesAutoresizingMaskIntoConstraints = NO; + + _direction = direction; + _insets = insets; + _alignment = alignment; + _spacing = spacing; + + } + return self; +} + +- (void)addSubview:(UIView *)view { +// NSAssert(NO, @"%@ allows to set subviews via %s only", self.class, sel_getName(@selector(setSubviews:))); +} + +/** Potentially can replace this with a predicate, so diffetent spacings can be set between items of different kinds. */ +- (CGFloat)spacingBetweenItem:(UIView *)item1 andItem:(UIView *)item2 { + return _spacing; +} + +- (NSLayoutAttribute)leadingAttribute { + return (_direction == MMMLayoutDirectionHorizontal) ? NSLayoutAttributeLeft : NSLayoutAttributeTop; +} + +- (NSLayoutAttribute)oppositeDirectionLeadingAttribute { + return (_direction == MMMLayoutDirectionHorizontal) ? NSLayoutAttributeTop : NSLayoutAttributeLeft; +} + +- (NSLayoutAttribute)trailingAttribute { + return (_direction == MMMLayoutDirectionHorizontal) ? NSLayoutAttributeRight : NSLayoutAttributeBottom; +} + +- (NSLayoutAttribute)oppositeDirectionTrailingAttribute { + return (_direction == MMMLayoutDirectionHorizontal) ? NSLayoutAttributeBottom : NSLayoutAttributeRight; +} + +- (NSLayoutAttribute)centerAttribute { + return (_direction == MMMLayoutDirectionHorizontal) ? NSLayoutAttributeCenterX : NSLayoutAttributeCenterY; +} + +- (NSLayoutAttribute)oppositeDirectionCenterAttribute { + return (_direction == MMMLayoutDirectionHorizontal) ? NSLayoutAttributeCenterY : NSLayoutAttributeCenterX; +} + +- (CGFloat)leadingInset { + return (_direction == MMMLayoutDirectionHorizontal) ? _insets.left : _insets.top; +} + +- (CGFloat)oppositeLeadingInset { + return (_direction == MMMLayoutDirectionHorizontal) ? _insets.top : _insets.left; +} + +- (CGFloat)trailingInset { + return (_direction == MMMLayoutDirectionHorizontal) ? _insets.right : _insets.bottom; +} + +- (CGFloat)oppositeTrailingInset { + return (_direction == MMMLayoutDirectionHorizontal) ? _insets.bottom : _insets.right; +} + +- (void)setSubviews:(NSArray *)subviews { + + if ([_managedSubviews isEqualToArray:subviews]) { + // This allows the user to rebuild the list of subviews without worrying about performance. + return; + } + + for (UIView *subview in _managedSubviews) { + [subview removeFromSuperview]; + } + _managedSubviews = [[NSMutableArray alloc] initWithArray:subviews]; + + BOOL pinLeading = (_alignment == MMMLayoutAlignmentLeading) || (_alignment == MMMLayoutAlignmentFill); + BOOL pinTrailing = (_alignment == MMMLayoutAlignmentTrailing) || (_alignment == MMMLayoutAlignmentFill); + + UIView *prevItem = nil; + + for (UIView *v in subviews) { + + [super addSubview:v]; + + // Opposite direction leading + [self addConstraint:[NSLayoutConstraint + constraintWithItem:v attribute:[self oppositeDirectionLeadingAttribute] + relatedBy: NSLayoutRelationGreaterThanOrEqual + toItem:self attribute:[self oppositeDirectionLeadingAttribute] + multiplier:1 constant:[self oppositeLeadingInset] + identifier:@"MMM-Opposite-Leading-GE" + ]]; + [self addConstraint:[NSLayoutConstraint + constraintWithItem:v attribute:[self oppositeDirectionLeadingAttribute] + relatedBy:NSLayoutRelationEqual + toItem:self attribute:[self oppositeDirectionLeadingAttribute] + multiplier:1 constant:[self oppositeLeadingInset] + priority:pinLeading ? UILayoutPriorityRequired : UILayoutPriorityDefaultLow + 1 + identifier:@"MMM-Opposite-Leading" + ]]; + + // Opposite direction leading trailing + [self addConstraint:[NSLayoutConstraint + constraintWithItem:v attribute:[self oppositeDirectionTrailingAttribute] + relatedBy:NSLayoutRelationLessThanOrEqual + toItem:self attribute:[self oppositeDirectionTrailingAttribute] + multiplier:1 constant:-[self oppositeTrailingInset] + identifier:@"MMM-Opposite-Trailing" + ]]; + [self addConstraint:[NSLayoutConstraint + constraintWithItem:v attribute:[self oppositeDirectionTrailingAttribute] + relatedBy:NSLayoutRelationEqual + toItem:self attribute:[self oppositeDirectionTrailingAttribute] + multiplier:1 constant:-[self oppositeTrailingInset] + priority:pinTrailing ? UILayoutPriorityRequired : UILayoutPriorityDefaultLow + 1 + identifier:@"MMM-Opposite-Trailing" + ]]; + + // Opposite direction center, if needed + if (_alignment == MMMLayoutHorizontalAlignmentCenter) { + [self addConstraint:[NSLayoutConstraint + constraintWithItem:v attribute:[self oppositeDirectionCenterAttribute] + relatedBy:NSLayoutRelationEqual + toItem:self attribute:[self oppositeDirectionCenterAttribute] + multiplier:1 constant:0 + identifier:@"MMM-Opposite-Center" + ]]; + } + + // Leading + if (!prevItem) { + // This is the topmost item, should be pinned to the top taking into account insets + [self addConstraint:[NSLayoutConstraint + constraintWithItem:v attribute:[self leadingAttribute] + relatedBy:NSLayoutRelationEqual + toItem:self attribute:[self leadingAttribute] + multiplier:1 constant:[self leadingInset] + identifier:@"MMM-Leading" + ]]; + } else { + [self addConstraint:[NSLayoutConstraint + constraintWithItem:v attribute:[self leadingAttribute] + relatedBy:NSLayoutRelationEqual + toItem:prevItem attribute:[self trailingAttribute] + multiplier:1 constant:[self spacingBetweenItem:prevItem andItem:v] + identifier:@"MMM-Spacer" + ]]; + } + + prevItem = v; + } + + // Don't forget to pin the bottom of the last item + if (prevItem) { + [self addConstraint:[NSLayoutConstraint + constraintWithItem:prevItem attribute:[self trailingAttribute] + relatedBy:NSLayoutRelationEqual + toItem:self attribute:[self trailingAttribute] + multiplier:1 constant:-[self trailingInset] + identifier:@"MMM-Trailing" + ]]; + } +} + +@end + +// +// +// +@implementation MMMVerticalStackContainer + +- (id)initWithInsets:(UIEdgeInsets)insets + alignment:(MMMLayoutHorizontalAlignment)alignment + spacing:(CGFloat)spacing +{ + return [super + initWithDirection:MMMLayoutDirectionVertical + insets:insets + alignment:MMMLayoutAlignmentFromHorizontalAlignment(alignment) + spacing:spacing + ]; +} + +@end + +@implementation MMMHorizontalStackContainer + +- (id)initWithInsets:(UIEdgeInsets)insets + alignment:(MMMLayoutVerticalAlignment)alignment + spacing:(CGFloat)spacing +{ + return [super + initWithDirection:MMMLayoutDirectionHorizontal + insets:insets + alignment:MMMLayoutAlignmentFromVerticalAlignment(alignment) + spacing:spacing + ]; +} + +@end + +// +// +// +@implementation MMMAutoLayoutIsolator + +- (id)initWithView:(UIView *)view { + + if (self = [super initWithFrame:CGRectZero]) { + + super.translatesAutoresizingMaskIntoConstraints = NO; + + _view = view; + _view.translatesAutoresizingMaskIntoConstraints = NO; + [super addSubview:_view]; + } + + return self; +} + +- (void)setTranslatesAutoresizingMaskIntoConstraints:(BOOL)translatesAutoresizingMaskIntoConstraints { + NSAssert(NO, @"Don't change translatesAutoresizingMaskIntoConstraints in %@", self.class); +} + +- (void)addSubview:(UIView *)view { + NSAssert(NO, @"Don't add subviews into %@ directly", self.class); +} + +- (void)layoutSubviews { + [super layoutSubviews]; + CGSize s = self.bounds.size; + _view.frame = CGRectMake(0, 0, s.width, s.height); +} + +- (CGSize)sizeThatFits:(CGSize)size { + // TODO: not sure about default fitting priorities here, can imagine they can make a difference sometimes + CGSize result = [_view + systemLayoutSizeFittingSize:size + withHorizontalFittingPriority:size.width < 1 ? UILayoutPriorityDefaultHigh - 1 : UILayoutPriorityFittingSizeLevel + verticalFittingPriority:size.height < 1 ? UILayoutPriorityDefaultHigh - 1 : UILayoutPriorityFittingSizeLevel + ]; + return MMMIntegralSize(result); +} + +// Well, can have it compatible a bit with Auto Layout world too. +- (CGSize)intrinsicContentSize { + return [self sizeThatFits:CGSizeZero]; +} + +@end + +// +// +// +@implementation MMMPaddedView + +- (id)initWithView:(UIView *)view insets:(UIEdgeInsets)insets { + + if (self = [super initWithFrame:CGRectZero]) { + + _view = view; + _insets = insets; + + self.translatesAutoresizingMaskIntoConstraints = NO; + + [super addSubview:_view]; + + [self + mmm_addConstraintsAligningView:_view + horizontally:MMMLayoutHorizontalAlignmentFill + vertically:MMMLayoutVerticalAlignmentFill + insets:_insets + ]; + } + + return self; +} + +- (void)addSubview:(UIView *)view { + NSAssert(NO, @"You are not supposed to add any subviews into %@", self.class); +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMLoadableImage.h b/Sources/MMMCommonUIObjC/MMMLoadableImage.h new file mode 100644 index 0000000..665ee40 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMLoadableImage.h @@ -0,0 +1,85 @@ +// +// MMMLoadable. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +#import "MMMLoadable.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * We need thumbnail images in a couple of places and they are not typically accessible immediately even if they sit in + * a local cache or DB. So here is a simple protocol based on MMMLoadable (which is kind of a "promise" object) to wrap + * such images. + */ +@protocol MMMLoadableImage + +/** The image itself. As always, this is available only when `contentsAvailable` is YES. */ +@property (nonatomic, readonly, nullable) UIImage *image; + +@end + +/** + * An image from the app's bundle (accessible via `+imageNamed:` method of UIImage) wrapped into MMMLoadableImage + * and loaded asynchronously. + */ +@interface MMMNamedLoadableImage : MMMLoadable + +- (id)initWithName:(NSString *)name NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; + +@end + +/** + * MMMLoadableImage-compatible wrapper for images that are immediatey available. + */ +@interface MMMImmediateLoadableImage : MMMLoadable + +- (id)initWithImage:(nullable UIImage *)image NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; + +@end + +/** + * Implementation of MMMLoadableImage for images that are publically accessible via a URL. + * This is very basic, using the shared instance of NSURLSession, so any caching will happen there. + */ +@interface MMMPublicLoadableImage : MMMLoadable + +- (id)initWithURL:(nullable NSURL *)url NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; + +@end + +/** + * This is used in unit tests when we want to manipulate the state of a MMMLoadableImage to verify it produces the needed + * effects on the views being tested. + */ +@interface MMMTestLoadableImage : MMMTestLoadable + +- (void)setDidSyncSuccessfullyWithImage:(nullable UIImage *)image; + +@end + +/** + * Sometimes an object implementing MMMLoadableImage is created much later than when it would be convenient to have one. + * + * A proxy can be used in this case, so the users still have a reference to MMMLoadableImage and can begin observing it + * or request a sync asap. Later when the actual reference is finally available it is supplied to the proxy which begins + * mirroring its state. + * + * As always, this is meant to be used only in the implementation, with only id visible publically. + */ +@interface MMMLoadableImageProxy : MMMLoadableProxy + +/** The image being proxied. */ +@property (nonatomic, readwrite, nullable) id loadable; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMLoadableImage.m b/Sources/MMMCommonUIObjC/MMMLoadableImage.m new file mode 100644 index 0000000..ed34e39 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMLoadableImage.m @@ -0,0 +1,296 @@ +// +// MMMLoadable. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMLoadableImage.h" +#import "MMMLoadable+Subclasses.h" + +@import MMMCommonCore; +@import MMMLog; + +// +// +// +@implementation MMMImmediateLoadableImage + +@synthesize image=_image; + +- (id)initWithImage:(UIImage *)image { + + if (self = [super init]) { + + _image = image; + + [self didFinish]; + } + + return self; +} + +- (BOOL)isContentsAvailable { + return _image != nil; +} + +- (void)didFinish { + if (_image) { + self.loadableState = MMMLoadableStateDidSyncSuccessfully; + } else { + self.loadableState = MMMLoadableStateDidFailToSync; + } +} + +- (void)doSync { + // Nothing to do for this one, either synced initially or never. + [self didFinish]; +} + +@end + +// +// +// +@implementation MMMNamedLoadableImage { + NSString *_name; +} + +@synthesize image = _image; + +- (id)initWithName:(NSString *)name { + + if (self = [super init]) { + _name = name; + } + + return self; +} + +- (BOOL)isContentsAvailable { + return _image != nil; +} + +- (void)didFinishWithImage:(UIImage *)image { + + _image = image; + + if (_image) { + + [self setDidSyncSuccessfully]; + + } else { + + MMM_LOG_ERROR(@"Could not load the image named '%@'", _name); + + [self setFailedToSyncWithError:nil]; + + // This class is for images coming from the app's bundle, so the loading failure is most likely a + // programmer's error and we need to crash asap. + NSAssert(NO, @"Image '%@' is not in the bundle?", _name); + } +} + +- (void)doSyncDeferred { + UIImage *image = [UIImage imageNamed:_name]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self didFinishWithImage:image]; + }); +} + +- (void)doSync { + dispatch_async( + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), + ^{ + // Yes, I want to capture self in this case since there is no way to cancel the load from our bundle anyway. + [self doSyncDeferred]; + } + ); +} + +@end + +// +// +// +@implementation MMMPublicLoadableImage { + NSURL *_url; + UIImage *_image; + NSURLSession *_session; + NSURLSessionTask *_downloadTask; +} + +@synthesize image=_image; + +// TODO: this is not nice: without the cache the image could be reused in MMMTemple + ++ (NSCache *)cache { + + static dispatch_once_t onceToken; + static NSCache *cache = nil; + dispatch_once(&onceToken, ^{ + cache = [[NSCache alloc] init]; + // Max 100 images + cache.countLimit = 100; + // Max 1 Mpixel + cache.totalCostLimit = 100 * 100 * 100; + }); + + return cache; +} + +- (id)initWithURL:(NSURL *)url { + + id cacheKey = url ?: [NSNull null]; + + id cachedInstance = [[MMMPublicLoadableImage cache] objectForKey:cacheKey]; + if (cachedInstance) + return cachedInstance; + + if (self = [super init]) { + _url = url; + _session = [NSURLSession sharedSession]; + } + + [[MMMPublicLoadableImage cache] setObject:self forKey:cacheKey cost:0]; + + return self; +} + +- (void)dealloc { + [_downloadTask cancel]; +} + +- (BOOL)isContentsAvailable { + return _image != nil; +} + +- (void)doSync { + + if (!_url) { + [self didFailWithError:[self errorWithMessage:@"No URL provided"]]; + return; + } + + _downloadTask = [_session + dataTaskWithURL:_url + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) + [self didFailWithError:error]; + else + [self didFinishSuccessfullyWithResponse:response data:data]; + } + ]; + [_downloadTask resume]; +} + +- (void)dispatch:(void (^)(void))block { + dispatch_async(dispatch_get_main_queue(), block); +} + +- (NSError *)errorWithMessage:(NSString *)message { + return [NSError + errorWithDomain:NSStringFromClass(self.class) + code:1 + userInfo:@{ + NSLocalizedDescriptionKey : message + } + ]; +} + +- (void)setFailedToSyncWithError:(NSError *)error { + + MMM_LOG_ERROR(@"Failed to fetch the image at '%@': %@", _url, [error localizedDescription]); + + [super setFailedToSyncWithError:error]; +} + +- (void)didFailWithError:(NSError *)error { + + [self dispatch:^{ + [self setFailedToSyncWithError:error]; + }]; +} + +- (void)didFinishSuccessfullyWithResponse:(NSURLResponse *)response data:(NSData *)data { + + NSAssert(![NSThread isMainThread], @""); + + if (!response) { + [self didFailWithError:[self errorWithMessage:@"No response"]]; + return; + } + + if (!data || [data length] == 0) { + [self didFailWithError:[self errorWithMessage:@"Empty response"]]; + return; + } + + if (![[response MIMEType] isEqual:@"image/jpeg"] && ![[response MIMEType] isEqual:@"image/jp2"] && + ![[response MIMEType] isEqual:@"image/png"] && ![[response MIMEType] isEqual:@"image/gif"]) { + + [self didFailWithError:[self errorWithMessage:[NSString + stringWithFormat:@"Unsupported MIME type: '%@'", [response MIMEType] + ]]]; + + return; + } + + UIImage *image = [[UIImage alloc] initWithData:data]; + if (image) { + + MMM_LOG_TRACE(@"Successfully fetched a %ldx%ld image from %@", (long)image.size.width, (long)image.size.height, _url); + + // Now we know the size of the image, let's update the cost in the cache. + [[MMMPublicLoadableImage cache] setObject:self forKey:_url cost:image.size.width * image.size.height]; + + [[MMMNetworkConditioner shared] + conditionBlock:^(NSError *error) { + [self dispatch:^{ + if (error) { + [self didFailWithError:error]; + } else { + self->_image = image; + self.loadableState = MMMLoadableStateDidSyncSuccessfully; + } + }]; + } + inContext:NSStringFromClass(self.class) + estimatedResponseLength:data.length + ]; + + } else { + [self didFailWithError:[self errorWithMessage:@"Could not decode the image data"]]; + } +} + +@end + +// +// +// +@implementation MMMTestLoadableImage + +@synthesize image = _image; + +- (void)setDidSyncSuccessfullyWithImage:(nullable UIImage *)image { + _image = image; + [self setDidSyncSuccessfully]; +} + +- (BOOL)isContentsAvailable { + return _image != nil; +} + +@end + +// +// +// +@implementation MMMLoadableImageProxy + +@dynamic loadable; + +- (UIImage *)image { + return self.loadable.image; +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMPhoto.h b/Sources/MMMCommonUIObjC/MMMPhoto.h new file mode 100644 index 0000000..aee3231 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMPhoto.h @@ -0,0 +1,74 @@ +// +// MMMLoadable. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +@import Foundation; + +#import "MMMLoadableImage.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, MMMPhotoContentMode) { + MMMPhotoContentModeAspectFit, + MMMPhotoContentModeAspectFill +}; + +/** + * Protocol for an image that can have different versions depending on the requested resolution. + * Each version is not necesserely available immediately (follows MMMLoadableImage protocol). + * + * (Using "photo" in the name to distinguish this from single fixed resulution images.) + */ +@protocol MMMPhoto + +/** + * A snapshot of the photo suitable for the target size. This way multiple images can be requested from the same photo, + * like a thumbnail and the large versions, for example. + * + * Note that the actual image returned can be larger than the target size, i.e. always treat it as a hint. + * + * And as always with loadables, don't assume certain state of the returned image, i.e. it can be completely loaded + * already, can be syncing or you might have to trigger sync. + */ +- (id)imageForTargetSize:(CGSize)targetSize contentMode:(MMMPhotoContentMode)contentMode; + +@end + +/** + * A photo picked from the Photo Library. We are trying to not fetch the actual image till it's needed. + */ +@interface MMMPhotoFromLibrary : NSObject + +/** The asset identifier that can be used to find the photo in the Library. */ +@property (nonatomic, readonly) NSString *localIdentifier; + +- (id)initWithLocalIdentifier:(NSString *)localIdentifier NS_DESIGNATED_INITIALIZER; +- (id)init NS_UNAVAILABLE; + +@end + +/** + * A regular UIImage wrapped into the WIGPhoto interface, can be handy for tests. + */ +@interface MMMPhotoFromUIImage : NSObject + +- (id)initWithImage:(UIImage *)image NS_DESIGNATED_INITIALIZER; +- (id)init NS_UNAVAILABLE; + +@end + +/** + * Another implementation of WIGPhoto handy for tests: the images are downloaded from a web service hosting + * placeholder images. + */ +@interface WIGTestPlaceholderPhoto : NSObject + +/** The index influences which image will be fetched, i.e. items with the same indexes should have the same picture. */ +- (instancetype)initWithIndex:(NSInteger)index NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMPhoto.m b/Sources/MMMCommonUIObjC/MMMPhoto.m new file mode 100644 index 0000000..04e3a3c --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMPhoto.m @@ -0,0 +1,98 @@ +// +// MMMLoadable. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMPhoto.h" + +#import "MMMPhotoLibraryLoadableImage.h" +@import Photos; + +// +// +// +@implementation MMMPhotoFromLibrary + +- (id)initWithLocalIdentifier:(NSString *)localIdentifier { + if (self = [super init]) { + _localIdentifier = localIdentifier; + } + return self; +} + +- (PHImageContentMode)PHImageContentModeFromContentMode:(MMMPhotoContentMode)contentMode { + switch (contentMode) { + case MMMPhotoContentModeAspectFit: + return PHImageContentModeAspectFit; + case MMMPhotoContentModeAspectFill: + return PHImageContentModeAspectFill; + } +} + +- (id)imageForTargetSize:(CGSize)targetSize contentMode:(MMMPhotoContentMode)contentMode { + return [[MMMPhotoLibraryLoadableImage alloc] + initWithLocalIdentifier:_localIdentifier + targetSize:targetSize + contentMode:[self PHImageContentModeFromContentMode:contentMode] + ]; +} + +- (NSString *)description { + return [NSString stringWithFormat:@"<%@: asset '%@'>", self.class, _localIdentifier]; +} + +@end + +// +// +// +@implementation MMMPhotoFromUIImage { + MMMImmediateLoadableImage *_loadable; + UIImage *_image; +} + +- (id)initWithImage:(UIImage *)image { + + if (self = [super init]) { + + // TODO: downscale it to the size that makes sense asap, then get rid of the original + _image = image; + } + + return self; +} + +- (id)imageForTargetSize:(CGSize)targetSize contentMode:(MMMPhotoContentMode)contentMode { + + // TODO: For now we don't trim it for different target sizes, but maybe we should downscale when a thumbnail is requested. + if (!_loadable) { + _loadable = [[MMMImmediateLoadableImage alloc] initWithImage:_image]; + } + + return _loadable; +} + +@end + +// +// +// +@implementation WIGTestPlaceholderPhoto { + NSInteger _index; +} + +- (instancetype)initWithIndex:(NSInteger)index { + + if (self = [super init]) { + _index = index; + } + + return self; +} + +- (id)imageForTargetSize:(CGSize)targetSize contentMode:(MMMPhotoContentMode)contentMode { + NSString *url = [NSString stringWithFormat:@"https://loremflickr.com/%li/%li?lock=%li", (long)targetSize.width, (long)targetSize.height, (long)_index]; + return [[MMMPublicLoadableImage alloc] initWithURL:[NSURL URLWithString:url]]; +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMPhotoLibraryLoadableImage.h b/Sources/MMMCommonUIObjC/MMMPhotoLibraryLoadableImage.h new file mode 100644 index 0000000..e1522eb --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMPhotoLibraryLoadableImage.h @@ -0,0 +1,38 @@ +// +// MMMLoadable. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMLoadable.h" +#import "MMMLoadableImage.h" + +@import Photos; + +NS_ASSUME_NONNULL_BEGIN + +/** + * Wraps images in the Photo Library as MMMLoadableImage. This is when you have an asset identifier already + * and then want to load the corresponding image. + * + * Note that this implementation is not suitable for the case when you need a lots of small thumbnails. + * It's better to user the Photos framework directly in this case. This is more suitable for fetching a bunch of larger images. + */ +@interface MMMPhotoLibraryLoadableImage : MMMLoadable + +/** The identifier of the the PHAsset which is used to find it in the Photo Library. */ +@property (nonatomic, readonly) NSString *localIdentifier; + +/** The approximate size of the target image. Passed on initialization. + * The resulting image won't be cropped and should be be able to "aspect fit" into a rectangle of this size, + * though the actual size of the image can be larger. */ +@property (nonatomic, readonly) CGSize targetSize; + +- (id)initWithLocalIdentifier:(NSString *)localIdentifier + targetSize:(CGSize)targetSize + contentMode:(PHImageContentMode)contentMode NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMPhotoLibraryLoadableImage.m b/Sources/MMMCommonUIObjC/MMMPhotoLibraryLoadableImage.m new file mode 100644 index 0000000..0a57fd4 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMPhotoLibraryLoadableImage.m @@ -0,0 +1,129 @@ +// +// MMMLoadable. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMPhotoLibraryLoadableImage.h" + +#import "MMMLoadable+Subclasses.h" +@import MMMCommonCore; + +// +// +// +@implementation MMMPhotoLibraryLoadableImage { + + PHImageContentMode _contentMode; + + PHImageManager *_imageManager; + + PHImageRequestID _requestID; + + // YES, if _requestID is valid (because there is no official invalid value for PHImageRequestID documented). + BOOL _requestIDValid; +} + +@synthesize image = _image; + +- (id)initWithLocalIdentifier:(NSString *)localIdentifier + targetSize:(CGSize)targetSize + contentMode:(PHImageContentMode)contentMode +{ + if (self = [super init]) { + _localIdentifier = localIdentifier; + _targetSize = targetSize; + _contentMode = contentMode; + _imageManager = [PHImageManager defaultManager]; + } + + return self; +} + +- (BOOL)isContentsAvailable { + return _image != nil; +} + +- (NSError *)errorWithMessage:(NSString *)message { + return [NSError mmm_errorWithDomain:NSStringFromClass(self.class) message:message]; +} + +- (void)doSyncDeferred { + + PHFetchResult *result = [PHAsset fetchAssetsWithLocalIdentifiers:@[ _localIdentifier ] options:nil]; + + PHAsset *asset = result.firstObject; + if (!asset) { + [self setFailedToSyncWithError:[self + errorWithMessage:[NSString stringWithFormat:@"Could not fetch the asset #%@", _localIdentifier] + ]]; + return; + } + + PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init]; + + // We want the latest version of the image with all the edits, etc. + // This is probably the default option, but it's not mentioned in the docs, so let's be explicit. + options.version = PHImageRequestOptionsVersionCurrent; + + // We want the best quality image, getting several calls is not interesting as this class + // is not designed to present a lot of images quickly, Photos should be used directly in this case. + options.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; + + // We are OK to get something larger than we want. + options.resizeMode = PHImageRequestOptionsResizeModeFast; + + typeof(self) __weak weakSelf = self; + _requestID = [_imageManager + requestImageForAsset:asset + targetSize:_targetSize + contentMode:_contentMode + options:options + resultHandler:^(UIImage * _Nullable result, NSDictionary * _Nullable info) { + [[MMMNetworkConditioner shared] + conditionBlock:^(NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + typeof(self) strongSelf = weakSelf; + if (error) { + [strongSelf didFinishRequestWithError:error image:nil info:nil]; + } else { + [strongSelf didFinishRequestWithError:nil image:result info:info]; + } + }); + } + inContext:NSStringFromClass(self.class) + estimatedResponseLength:0 + ]; + } + ]; + _requestIDValid = YES; +} + +- (void)didFinishRequestWithError:(NSError *)error image:(UIImage *)image info:(NSDictionary *)info { + + if (image) { + _image = image; + [self setDidSyncSuccessfully]; + } else { + [self setFailedToSyncWithError:error ?: [self + errorWithMessage:[NSString + stringWithFormat:@"Could not fetch the image for target size %@", + NSStringFromCGSize(_targetSize) + ] + ]]; + } +} + +- (void)doSync { + + // Let's offload this to a queue just in case the access to fetchAssetsWithLocalIdentifiers: is slow. + typeof(self) __weak weakSelf = self; + dispatch_async( + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), + ^{ + typeof(self) strongSelf = weakSelf; + [strongSelf doSyncDeferred]; + } + ); +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMScrollViewShadows.h b/Sources/MMMCommonUIObjC/MMMScrollViewShadows.h new file mode 100644 index 0000000..d06f53d --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMScrollViewShadows.h @@ -0,0 +1,78 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +@class MMMScrollViewShadowsSettings; + +/** + * A helper for adding top and bottom shadows into any UIScrollView-based class. + * You create an instance in your subclass and forward calls from layoutSubviews. + */ +@interface MMMScrollViewShadows : NSObject + +- (nonnull id)initWithScrollView:(nonnull UIScrollView *)scrollView + settings:(nonnull MMMScrollViewShadowsSettings *)settings NS_DESIGNATED_INITIALIZER; + +- (nonnull id)init NS_UNAVAILABLE; + +/** Have to be called from `layoutSubviews` of our scroll view subclass to update the state of the shadows. */ +- (void)layoutSubviews; + +/** YES, if additional content view clipping might be needed for the current shadow settings. */ +- (BOOL)mightNeedClippingView; + +/** Same as `layoutSubviews` above but also updates `clipToBounds` property of the given view in case there are visible + * shadows that are not flush with the edges of our scroll view, i.e. when top/bottomShadowShouldUseContentInsets + * are used with settings and the corresponding insets are not zero now. */ +- (void)layoutSubviewsWithClippingView:(nullable UIView *)clippingView; + +@end + +/** + * Holds configuration for MMMScrollViewShadows that can be set only on initialization time. + */ +@interface MMMScrollViewShadowsSettings : NSObject + +/** The base shadow color is black with this amount of transparency applied to it. */ +@property (nonatomic, readwrite) CGFloat shadowAlpha; + +/** + * The value between 0 and 1 telling how close to an elliptical curve the shadow's border should be. + * + * - when it's 0, then the shadow is a normal rectangular one. + * + * - when it's 1, then the gradient of the top (bottom) shadow forms an arc crossing the center of a shadow view and + * its both corners. + * + * All values in-between adjust the point at which the gradient crosses the sides of the shadow views. + * + * (The default value is 0.5.) + */ +@property (nonatomic, readwrite) CGFloat shadowCurvature; + +/** Disabled by default. */ +@property (nonatomic, readwrite) BOOL topShadowEnabled; + +/** The height of the top shadow view. (5px by default.) */ +@property (nonatomic, readwrite) CGFloat topShadowHeight; + +/** YES, if the top shadow should be offset from the top edge of the scroll view by the top offset of content insets. + * The default value is NO. */ +@property (nonatomic, readwrite) BOOL topShadowShouldUseContentInsets; + +/** Disabled by default. */ +@property (nonatomic, readwrite) BOOL bottomShadowEnabled; + +/** The height of the bottom shadow view. (10px by default.) */ +@property (nonatomic, readwrite) CGFloat bottomShadowHeight; + +/** YES, if the bottom shadow should be offset from the bottom edge of the scroll view by the bottom offset of content insets. + * The default value is NO. */ +@property (nonatomic, readwrite) BOOL bottomShadowShouldUseContentInsets; + +- (nonnull id)init NS_DESIGNATED_INITIALIZER; + +@end diff --git a/Sources/MMMCommonUIObjC/MMMScrollViewShadows.m b/Sources/MMMCommonUIObjC/MMMScrollViewShadows.m new file mode 100644 index 0000000..6dd2dd7 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMScrollViewShadows.m @@ -0,0 +1,266 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMScrollViewShadows.h" + +#import "MMMAnimations.h" +#import "MMMCommonUI.h" + +// +// +// +@implementation MMMScrollViewShadowsSettings + +- (id)init { + + if (self = [super init]) { + + _shadowAlpha = .3; + _shadowCurvature = 0.5; + + _topShadowEnabled = NO; + _topShadowHeight = 5; + _topShadowShouldUseContentInsets = NO; + + _bottomShadowEnabled = NO; + _bottomShadowHeight = 10; + _bottomShadowShouldUseContentInsets = NO; + } + + return self; +} + +- (id)copyWithZone:(NSZone *)zone { + + MMMScrollViewShadowsSettings *result = [[MMMScrollViewShadowsSettings alloc] init]; + + result.shadowAlpha = self.shadowAlpha; + result.shadowCurvature = self.shadowCurvature; + + result.topShadowEnabled = self.topShadowEnabled; + result.topShadowHeight = self.topShadowHeight; + result.topShadowShouldUseContentInsets = self.topShadowShouldUseContentInsets; + + result.bottomShadowEnabled = self.bottomShadowEnabled; + result.bottomShadowHeight = self.bottomShadowHeight; + result.bottomShadowShouldUseContentInsets = self.bottomShadowShouldUseContentInsets; + + return result; +} + +@end + +// +// +// + +typedef NS_ENUM(NSInteger, MMMScrollViewShadowAlignment) { + MMMScrollViewShadowAlignmentTop, + MMMScrollViewShadowAlignmentBottom +}; + +@interface MMMScrollViewShadowView : UIView + +- (id)initWithAlignment:(MMMScrollViewShadowAlignment)alignment + settings:(MMMScrollViewShadowsSettings *)settings NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; + +@end + +@implementation MMMScrollViewShadowView { + MMMScrollViewShadowAlignment _alignment; + MMMScrollViewShadowsSettings *_settings; +} + +- (id)initWithAlignment:(MMMScrollViewShadowAlignment)alignment settings:(MMMScrollViewShadowsSettings *)settings { + + if (self = [super initWithFrame:CGRectZero]) { + + _alignment = alignment; + _settings = settings; + + self.opaque = NO; + self.contentMode = UIViewContentModeRedraw; + + self.translatesAutoresizingMaskIntoConstraints = NO; + } + + return self; +} + +- (void)drawRect:(CGRect)rect { + + CGRect b = self.bounds; + + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + + const CGFloat shadowAlpha = _settings.shadowAlpha; + + NSInteger numberOfSteps = MAX(8, MIN(b.size.height * 2, 24)); + CGFloat *colors = alloca(4 * sizeof(CGFloat) * numberOfSteps); + CGFloat *steps = alloca(sizeof(CGFloat) * numberOfSteps); + for (NSInteger i = 0; i < numberOfSteps; i++) { + CGFloat t = (CGFloat)i / (numberOfSteps - 1); + steps[i] = t; + colors[i * 4 + 0] = 0; + colors[i * 4 + 1] = 0; + colors[i * 4 + 2] = 0; + colors[i * 4 + 3] = powf( + [MMMAnimation + interpolateFrom:shadowAlpha to:0 + time:t + startTime:0 duration:1 + curve:MMMAnimationCurveSofterEaseOut + ], + M_SQRT2 + ); + } + + CGGradientRef gradient = CGGradientCreateWithColorComponents(colorSpace, colors, steps, numberOfSteps); + + CGColorSpaceRelease(colorSpace); + + // We want the radial gradient to end at the arc passing via the center of the bottom edge and both top corners (for the top shadow view) + CGFloat d = b.size.width * .5; + CGFloat h = b.size.height * MAX(.01, _settings.shadowCurvature); + CGFloat radius = (d * d + h * h) / (2 * h); + + CGPoint center; + if (_alignment == MMMScrollViewShadowAlignmentTop) { + center = CGPointMake(b.origin.x + d, b.origin.y + b.size.height - radius); + } else { + NSAssert(_alignment == MMMScrollViewShadowAlignmentBottom, @""); + center = CGPointMake(b.origin.x + d, b.origin.y + radius); + } + + CGContextRef c = UIGraphicsGetCurrentContext(); + + CGContextDrawRadialGradient( + c, gradient, + center, radius - b.size.height, + center, radius, + 0 + ); + + CGGradientRelease(gradient); +} + +@end + +@implementation MMMScrollViewShadows { + UIScrollView * __weak _scrollView; + MMMScrollViewShadowsSettings *_settings; + MMMScrollViewShadowView *_topShadowView; + MMMScrollViewShadowView *_bottomShadowView; +} + +- (id)initWithScrollView:(UIScrollView *)scrollView settings:(MMMScrollViewShadowsSettings *)settings { + + if (self = [super init]) { + + _scrollView = scrollView; + _settings = settings; + + if (_settings.topShadowEnabled) { + _topShadowView = [[MMMScrollViewShadowView alloc] initWithAlignment:MMMScrollViewShadowAlignmentTop settings:_settings]; + [_scrollView addSubview:_topShadowView]; + } + + if (_settings.bottomShadowEnabled) { + _bottomShadowView = [[MMMScrollViewShadowView alloc] initWithAlignment:MMMScrollViewShadowAlignmentBottom settings:_settings]; + [_scrollView addSubview:_bottomShadowView]; + } + } + + return self; +} + +- (BOOL)mightNeedClippingView { + return _settings.topShadowShouldUseContentInsets || _settings.bottomShadowShouldUseContentInsets; +} + +- (void)layoutSubviews { + [self layoutSubviewsWithClippingView:nil]; +} + +- (void)layoutSubviewsWithClippingView:(nullable UIView *)clippingView { + + UIEdgeInsets contentInsets; + if (@available(iOS 11.0, *)) { + contentInsets = _scrollView.adjustedContentInset; + } else { + contentInsets = _scrollView.contentInset; + } + + CGRect b = UIEdgeInsetsInsetRect( + _scrollView.bounds, + UIEdgeInsetsMake( + _settings.topShadowShouldUseContentInsets ? contentInsets.top : 0, + 0, + _settings.bottomShadowShouldUseContentInsets ? contentInsets.bottom : 0, + 0 + ) + ); + + BOOL needsClipping = NO; + + if (_topShadowView) { + + CGFloat top = CGRectGetMinY(b); + + CGFloat topShadowHeight = [MMMAnimation + interpolateFrom:0 to:_settings.topShadowHeight + time:top + startTime:0 duration:MAX(40, 4 * _settings.topShadowHeight) + curve:MMMAnimationCurveEaseInOut + ]; + + _topShadowView.frame = CGRectMake(CGRectGetMinX(b), top, b.size.width, topShadowHeight); + + BOOL hidden = topShadowHeight < 1; + _topShadowView.hidden = hidden; + + if (clippingView) + needsClipping = needsClipping || (!hidden && contentInsets.top > 0); + + if (!_topShadowView.hidden) + [_scrollView bringSubviewToFront:_topShadowView]; + } + + if (_bottomShadowView) { + + CGFloat bottom = CGRectGetMaxY(b); + + CGFloat bottomShadowHeight = [MMMAnimation + interpolateFrom:0 to:_settings.bottomShadowHeight + time:_scrollView.contentSize.height - bottom + startTime:_settings.bottomShadowHeight + duration:MAX(40, 4 * _settings.bottomShadowHeight) + curve:MMMAnimationCurveEaseInOut + ]; + + _bottomShadowView.frame = CGRectMake(CGRectGetMinX(b), bottom - bottomShadowHeight, b.size.width, bottomShadowHeight); + + BOOL hidden = bottomShadowHeight < 1; + _bottomShadowView.hidden = hidden; + + if (clippingView) + needsClipping = needsClipping || (!hidden && contentInsets.bottom > 0); + + if (!_bottomShadowView.hidden) + [_scrollView bringSubviewToFront:_bottomShadowView]; + } + + if (clippingView) { + // Conversion is not really needed as _scrollView is actually the superview, but we don't enforce it. + clippingView.frame = [_scrollView convertRect:b toView:clippingView.superview]; + clippingView.clipsToBounds = needsClipping; + } +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMShadowView.h b/Sources/MMMCommonUIObjC/MMMShadowView.h new file mode 100644 index 0000000..30ef69c --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMShadowView.h @@ -0,0 +1,68 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class MMMShadowViewSetting; + +typedef void (^ _Nonnull MMMShadowViewSettingBlock)(MMMShadowViewSetting *setting); + +/** + * Holds configuration for MMMShadowView. + */ +@interface MMMShadowViewSetting : NSObject + +/** Default is black color. */ +@property (nonatomic, readwrite) UIColor *color; + +/** Default is 0. */ +@property (nonatomic, readwrite) CGFloat opacity; + +/** Default is zero. */ +@property (nonatomic, readwrite) CGSize offset; + +/** Default is 0. */ +@property (nonatomic, readwrite) CGFloat radius; + +/** Default is zero. */ +@property (nonatomic, readwrite) UIEdgeInsets insets; + +/** Default is white color. */ +@property (nonatomic, readwrite) UIColor *backgroundColor; + +/** Default is 0. */ +@property (nonatomic, readwrite) CGFloat cornerRadius; + +// TODO: Add support for path. + +- (id)init; + +- (id)initWithBlock:(MMMShadowViewSettingBlock)block; + +@end + +#pragma mark - + +/** +* Helper view for adding custom layer shadows, while taking the the shadow sizes in conserderation for its final frame. +*/ +@interface MMMShadowView : UIView + +/** View that can accepts and lay out subviews. */ +@property (nonatomic, readonly) UIView *contentView; + +@property (nonatomic, readwrite, nullable) NSArray *settings; + +- (id)init; +- (id)initWithSettings:(nullable NSArray *)settings; + +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMShadowView.m b/Sources/MMMCommonUIObjC/MMMShadowView.m new file mode 100644 index 0000000..fcdd9f0 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMShadowView.m @@ -0,0 +1,206 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMShadowView.h" + +/** + * Something that holds both a CALayer & MMMShadowViewSettings together. + */ +@interface MMMShadowLayerInfo : NSObject + +@property (nonatomic, readonly) CALayer *layer; + +@property (nonatomic, readonly) MMMShadowViewSetting *setting; + +- (id)initWithLayer:(CALayer *)layer setting:(MMMShadowViewSetting *)setting NS_DESIGNATED_INITIALIZER; +- (id)init NS_UNAVAILABLE; + +@end + +#pragma mark - MMMShadowView + +@implementation MMMShadowView { + UIView *_contentView; + NSMutableArray *_layerInfo; +} + +- (id)initWithSettings:(NSArray *)settings +{ + self = [super initWithFrame:CGRectZero]; + if (self) { + + self.translatesAutoresizingMaskIntoConstraints = NO; + self.contentMode = UIViewContentModeRedraw; + self.backgroundColor = [UIColor clearColor]; + + _layerInfo = [[NSMutableArray alloc] init]; + + [self addSubview:[self contentView]]; + + // + // Layout + // + NSDictionary *views = NSDictionaryOfVariableBindings(_contentView); + + [NSLayoutConstraint activateConstraints:[NSLayoutConstraint + constraintsWithVisualFormat:@"H:|-0-[_contentView]-0-|" + options:0 metrics:nil views:views + ]]; + [NSLayoutConstraint activateConstraints:[NSLayoutConstraint + constraintsWithVisualFormat:@"V:|-0-[_contentView]-0-|" + options:0 metrics:nil views:views + ]]; + + self.settings = settings; + } + return self; +} + +- (id)init { + return [self initWithSettings:nil]; +} + +- (UIView *)contentView { + if (!_contentView) { + _contentView = [[UIView alloc] initWithFrame:CGRectZero]; + _contentView.translatesAutoresizingMaskIntoConstraints = NO; + } + return _contentView; +} + +- (void)layoutSubviews { + + [super layoutSubviews]; + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + + for (MMMShadowLayerInfo *info in _layerInfo) { + info.layer.frame = UIEdgeInsetsInsetRect(_contentView.frame, info.setting.insets); + } + + [CATransaction commit]; +} + +- (void)setSettings:(NSArray *)settings { + if (_settings != settings) { + _settings = settings; + [self createLayers]; + } +} + +- (void)createLayers { + + for (MMMShadowLayerInfo *info in _layerInfo) { + [info.layer removeFromSuperlayer]; + } + [_layerInfo removeAllObjects]; + + if (!_settings) { + _settings = nil; + return; + } + + for (MMMShadowViewSetting *setting in _settings) { + + CALayer *layer = [CALayer layer]; + layer.backgroundColor = setting.backgroundColor.CGColor; + layer.shadowColor = setting.color.CGColor; + layer.shadowOffset = setting.offset; + layer.shadowRadius = setting.radius; + layer.shadowOpacity = setting.opacity; + layer.cornerRadius = setting.cornerRadius; + + [self.layer addSublayer:layer]; + + [_layerInfo addObject:[[MMMShadowLayerInfo alloc] initWithLayer:layer setting:setting]]; + } + + [self bringSubviewToFront:_contentView]; + + [self invalidateIntrinsicContentSize]; +} + +- (UIEdgeInsets)alignmentRectInsets { + + UIEdgeInsets insets = UIEdgeInsetsZero; + + for (MMMShadowLayerInfo *info in _layerInfo) { + + CGSize offset = info.setting.offset; + CGFloat radius = info.setting.radius; + + UIEdgeInsets b = UIEdgeInsetsMake( + -offset.height + radius, + -offset.width + radius, + offset.height + radius, + offset.width + radius + ); + + // Don't want to depend on 'MMMMaxUIEdgeInsets()'. + insets = UIEdgeInsetsMake( + MAX(insets.top, b.top), + MAX(insets.left, b.left), + MAX(insets.bottom, b.bottom), + MAX(insets.right, b.right) + ); + } + + return insets; +} + +- (BOOL)requiresConstraintBasedLayout { + return YES; +} + +@end + +#pragma mark - MMMShadowViewSetting + +@implementation MMMShadowViewSetting + +- (id)initWithBlock:(MMMShadowViewSettingBlock)block +{ + self = [self init]; + if (self) { + block(self); + } + return self; +} + +- (id)init +{ + self = [super init]; + if (self) { + + _color = [UIColor blackColor]; + _opacity = 0.0; + _offset = CGSizeZero; + _radius = 0.0; + _insets = UIEdgeInsetsZero; + _backgroundColor = [UIColor whiteColor]; + _cornerRadius = 0.0; + } + return self; +} + +@end + +#pragma mark - MMMShadowLayerInfo + +@implementation MMMShadowLayerInfo + +- (id)initWithLayer:(CALayer *)layer setting:(MMMShadowViewSetting *)setting +{ + self = [super init]; + if (self) { + + _layer = layer; + _setting = setting; + } + return self; +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMStubView.h b/Sources/MMMCommonUIObjC/MMMStubView.h new file mode 100644 index 0000000..66a781d --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMStubView.h @@ -0,0 +1,26 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * To be used during development as a placeholder for not yet implemented views. + * It inherits a vertical scroll view so it's possible to see that gesture recognizers of the container do not interfere + * with a typical scrolling. + */ +@interface MMMStubView : UIScrollView + +/** The text is optional, the index influences the background color. */ +- (id)initWithText:(nullable NSString *)text index:(NSInteger)index NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMStubView.m b/Sources/MMMCommonUIObjC/MMMStubView.m new file mode 100644 index 0000000..9c9c09c --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMStubView.m @@ -0,0 +1,53 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMStubView.h" + +#import "MMMCommonUI.h" +#import "MMMLayout.h" + +@implementation MMMStubView { + UILabel *_label; + NSString *_text; + NSInteger _index; +} + +- (id)initWithText:(NSString *)text index:(NSInteger)index { + + if (self = [super initWithFrame:CGRectZero]) { + + _text = text; + _index = index; + + self.opaque = YES; + self.translatesAutoresizingMaskIntoConstraints = NO; + + _label = [[UILabel alloc] init]; + _label.font = [UIFont fontWithName:@"HelveticaNeue-Bold" size:17]; + _label.textColor = [UIColor whiteColor]; + [self addSubview:_label]; + + self.backgroundColor = MMMDebugColor(_index); + _label.text = ([_text length] > 0) ? _text : [NSString stringWithFormat:@"Stub #%ld", (long)_index]; + _label.textColor = MMMDebugColor(_index + 1); + } + + return self; +} + +- (void)layoutSubviews { + + CGRect b = self.bounds; + b.origin = CGPointZero; + b = UIEdgeInsetsInsetRect(b, UIEdgeInsetsMake(10, 10, 10, 10)); + + CGSize labelSize = [_label sizeThatFits:CGSizeMake(b.size.width, b.size.height)]; + _label.frame = [MMMLayoutUtils rectWithSize:labelSize withinRect:b contentMode:UIViewContentModeCenter]; + + self.contentSize = CGSizeMake(self.bounds.size.width, self.bounds.size.height * 2); +} + +@end + diff --git a/Sources/MMMCommonUIObjC/MMMStubViewController.h b/Sources/MMMCommonUIObjC/MMMStubViewController.h new file mode 100644 index 0000000..9a5b14c --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMStubViewController.h @@ -0,0 +1,23 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * This is to be used during development to stub not ready yet parts of the app. + */ +@interface MMMStubViewController : UIViewController + +- (id)initWithText:(NSString *)text index:(NSInteger)index NS_DESIGNATED_INITIALIZER; + +- (id)init NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (id)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMStubViewController.m b/Sources/MMMCommonUIObjC/MMMStubViewController.m new file mode 100644 index 0000000..d904eaf --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMStubViewController.m @@ -0,0 +1,121 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMStubViewController.h" + +#import "MMMStubView.h" + +@import MMMLog; + +/** + * We want the stub view controller to check if its appearance methods are called correctly, + * which helps with debugging of custom view controllers. + */ +typedef NS_ENUM(NSInteger, MMMStubViewControllerAppearanceState) { + MMMStubViewControllerAppearanceStateDisappeared, + MMMStubViewControllerAppearanceStateAppearing, + MMMStubViewControllerAppearanceStateDisappearing, + MMMStubViewControllerAppearanceStateAppeared +}; + +// +// +// +@interface MMMStubViewController () +@end + +@implementation MMMStubViewController { + + MMMStubView *__weak _view; + + NSString *_text; + NSInteger _index; + + MMMStubViewControllerAppearanceState _appearanceState; +} + +- (NSString *)mmm_instanceNameForLogging { + return [NSString stringWithFormat:@"%ld", (long)_index]; +} + +- (id)initWithText:(NSString *)text index:(NSInteger)index { + + if (self = [super initWithNibName:nil bundle:nil]) { + _text = text; + _index = index; + _appearanceState = MMMStubViewControllerAppearanceStateDisappeared; + } + + return self; +} + +- (NSString *)description { + return [NSString stringWithFormat:@"<%@:%p #%ld '%@'>", self.class, self, (long)_index, _text]; +} + +- (void)loadView { + MMMStubView *view = [[MMMStubView alloc] initWithText:_text index:_index]; + self.view = _view = view; +} + +- (void)setAppearanceState:(MMMStubViewControllerAppearanceState)state { + + if (_appearanceState == MMMStubViewControllerAppearanceStateDisappeared) { + + NSAssert(state == MMMStubViewControllerAppearanceStateAppearing, @""); + + } else if (_appearanceState == MMMStubViewControllerAppearanceStateAppearing) { + + NSAssert( + state == MMMStubViewControllerAppearanceStateAppeared + || state == MMMStubViewControllerAppearanceStateDisappearing, + @"" + ); + + } else if (_appearanceState == MMMStubViewControllerAppearanceStateAppeared) { + + NSAssert( + state == MMMStubViewControllerAppearanceStateDisappearing + || state == MMMStubViewControllerAppearanceStateDisappeared, + @"" + ); + + } else if (_appearanceState == MMMStubViewControllerAppearanceStateDisappearing) { + + NSAssert( + state == MMMStubViewControllerAppearanceStateDisappeared + || state == MMMStubViewControllerAppearanceStateAppearing, + @"" + ); + } + + _appearanceState = state; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + MMM_LOG_TRACE(@"%s%d", sel_getName(_cmd), animated); + [self setAppearanceState:MMMStubViewControllerAppearanceStateAppearing]; +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + MMM_LOG_TRACE(@"%s%d", sel_getName(_cmd), animated); + [self setAppearanceState:MMMStubViewControllerAppearanceStateAppeared]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + MMM_LOG_TRACE(@"%s%d", sel_getName(_cmd), animated); + [self setAppearanceState:MMMStubViewControllerAppearanceStateDisappearing]; +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + MMM_LOG_TRACE(@"%s%d", sel_getName(_cmd), animated); + [self setAppearanceState:MMMStubViewControllerAppearanceStateDisappeared]; +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMStylesheet.h b/Sources/MMMCommonUIObjC/MMMStylesheet.h new file mode 100644 index 0000000..96fb057 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMStylesheet.h @@ -0,0 +1,212 @@ +// +// MMMUtil. +// Copyright (C) 2015-2016 MediaMonks. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol MMMStylesheetConverter; + +/** + * A base for app-specific stylesheets: commonly used paddings, colors, fonts, etc in a single place. + */ +@interface MMMStylesheet : NSObject + +/** @{ */ + +/** + * Helpers that can be used to pick paddings, font sizes, minimum sizes of UI element, etc roughly depending on the + * physical size of the current device. + * + * Normally the end user of your stylesheet should not use these methods but instead have access to already prepared + * fonts, paddings and element sizes as convenient properties which in turn use these helpers in their implementation. + * + * We don't use precise PPIs or actual screen sizes here, but rather broad size classes like "Classic iPhone" or "iPad". + * See MMMSize* string constants. These size classes can be used in parallel with Apple's as the latter tell more about + * the context the particular view is in than about the size of the device. + */ + +/** The size class of the current device. See the MMSize* string constants below. */ +@property (nonatomic, readonly) NSString *currentSizeClass; + +/** + * Allows to avoid code that picks values (fonts, sizes, etc) by explicitely matching `currentSizeClass`. + * A mapping of size classes to values is passed here instead and a match is returned, falling back either to the value + * under MMMSizeRest key, or, if it is not present, to the value under the key that seems the closest to the current + * size class. + */ +- (id)valueForCurrentSizeClass:(NSDictionary *)sizeClassToValue; + +/** A version of `valueForCurrentSizeClass:` unwrapping the result as a float, which is handy for numeric values. */ +- (CGFloat)floatForCurrentSizeClass:(NSDictionary *)sizeClassToValue; + +/** + * Deprecated. + * Similar to `floatForCurrentSizeClass:` but instead of falling back to the value under MMMSizeRest key + * it tries to extrapolate the requested dimension using 1 or 2 values provided for other size classes using + * the `widthBasedConverter`. + */ +- (CGFloat)extrapolatedFloatForCurrentSizeClass:(NSDictionary *)sizeClassToValue + DEPRECATED_MSG_ATTRIBUTE("Try using `widthBasedConverter` instead or a custom converter if you relied on 2 dimensions"); + +/** + * Deprecated. + * Similar to `extrapolatedFloatForCurrentSizeClass:`, but allows to override values for certain size classes + * in the `exceptions` paramater. + */ +- (CGFloat)extrapolatedFloatForCurrentSizeClass:(NSDictionary *)sizeClassToValue + except:(NSDictionary *)exceptions + DEPRECATED_MSG_ATTRIBUTE("The code using this might be confusing and/or hard to support. If you need to specify values for different size classes, then list them all explicitly in a call to floatForCurrentSizeClass:"); + +/** + * Converts dimensions given for one size class into dimensions suitable for the current size class + * based on the ratio of screen widths associated with the current and source size classes. + */ +@property (nonatomic, readonly) id widthBasedConverter; + +/** @} */ + +/** @{ */ + +/** + * A standard set of paddings. + * The actual stylesheet should override all these or at least the `normalPadding`. + * They are defined here so `insetsFromRelativeInsets` can be defined here as well. + * In case only `normalPadding` is overriden then the rest will be calculated based on it using sqrt(2) as a multiplier, + * so every second padding is exactly 2x larger. + */ + +@property (nonatomic, readonly) CGFloat extraExtraSmallPadding; +@property (nonatomic, readonly) CGFloat extraSmallPadding; +@property (nonatomic, readonly) CGFloat smallPadding; +@property (nonatomic, readonly) CGFloat normalPadding; +@property (nonatomic, readonly) CGFloat largePadding; +@property (nonatomic, readonly) CGFloat extraLargePadding; + +/** @} */ + +/** + * Actual insets from relative ones. + * + * Each offset in relative insets is a fixed number corresponding to the actual paddings defined above: + * + * - .125 - extraExtraSmallPadding + * - .25 — extraSmallPadding + * - .5 — smallPadding + * - 1 — normalPadding + * - 2 — largePadding + * - 4 — extraLargePadding + * + * Note that the large padding is not necessarily 2x larger than the normal one, etc (by default the extra large is), + * but we intentionally use them here like this to allow more compact notation for insets which is easy to remember and + * easy to tweak. Compare, for example: + * + * \code + * UIEdgeInsetsMake([MHStylesheet shared].normalPadding, [MHStylesheet shared].largePadding, [MHStylesheet shared].normalPadding, [MHStylesheet shared].largePadding) + * \endcode + * + * and the equivalent: + * + * \code + * [[MHStylesheet shared] insetsFromRelativeInsets:UIEdgeInsetsMake(1, 2, 1, 2)] + * \endcode + * + */ +- (UIEdgeInsets)insetsFromRelativeInsets:(UIEdgeInsets)insets; + +/** This is what `insetsFromRelativeInsets:` is using internally. Might be useful when making similar methods. */ +- (CGFloat)paddingFromRelativePadding:(CGFloat)padding; + +/** + * A metrics dictionary that can be used with Auto Layout with keys/values corresponding to all the paddings we support, + * e.g. "extraSmallPadding", etc. + */ +- (NSDictionary *)dictionaryWithPaddings; + +/** + * A dictionary with 4 values under keys "Top", "Bottom", "Left", "Right" + * corresponding to the insets obtained from the provided relative ones via `insetsFromRelativeInsets:`. + * (A shortcut composing `insetsFromRelativeInsets` method with `MMMDictinaryFromUIEdgeInsets()`.) + */ +- (NSDictionary *)dictionaryFromRelativeInsets:(UIEdgeInsets)insets keyPrefix:(NSString *)keyPrefix; + +/** + * A dictionary with 4 values obtained from the insets returned by `insetsFromRelativeInsets:insets` + * under the keys "paddingTop", "paddingBottom", "paddingLeft", "paddingRight", + * i.e. it's a shortcut for `dictionaryFromRelativeInsets:insets keyPrefix:@"padding"`. + */ +- (NSDictionary *)paddingDictionaryFromRelativeInsets:(UIEdgeInsets)insets; + +@end + +/** @{ */ + +/** + * Identifiers of screen size classes we normally use to pick dimensions. + * (This is not an enum because it's easier to use them in dictionaries this way.) + * Please don't use them to identifiy devices, think of them as of "small", "medium", and "large" instead. + */ + +/** Small screen phones: iPhone 4/4s/5/5s/SE. */ +extern NSString * const MMMSizeClassic; + +/** Regular phones: iPhone 6/6s/7/8 and X as well. */ +extern NSString * const MMMSize6; + +/** Pluse-sized phones: iPhone 6/7/8 Plus. */ +extern NSString * const MMMSize6Plus; + +/** iPads: regular and pros. */ +extern NSString * const MMMSizePad; + +/** Not the actual size class, but can be used as a key `valueForCurrentSizeClass:` and related methods for a fallback value. */ +extern NSString * const MMMSizeRest; + +/** @} */ + +/** + * Something that converts dimensions given for one size class (e.g. font sizes from the design made for iPhone 6) + * into dimensions for another size class (e.g. font size for iPhone 5 that were not explicitely mentioned in the design). + * + * Different converters can be used for different kinds of values. For example, it might make sense to scale paddings + * proportionally to screen widths, but keep font sizes the same. + */ +@protocol MMMStylesheetConverter + +/** Converts a dimension know for certain size class according to the rules of the converter. */ +- (CGFloat)convertFloat:(CGFloat)value fromSizeClass:(NSString *)sourceSizeClass; + +@end + +/** + * Dimension converter that uses a table of scales. + */ +@interface MMMStylesheetScaleConverter : NSObject + +/** + * Initializes the converter with an explicit table of scales. + * Every value coming to `convertFloat:fromSizeClass:` will be returned multiplied by scales[sourceSizeClass]. + */ +- (id)initWithScales:(NSDictionary *)scales NS_DESIGNATED_INITIALIZER; + +/** + * Initializes the converter with a target size class and a table of dimensions associated with every size class + * (e.g screen width). + * + * Every value coming to `convertFloat:fromSizeClass:` will be returned adjusted proportionally to the ratio of the + * dimensions associated with target and source size classes, i.e. it will be multiplied by + * scales[targetSizeClass] / scales[sourceSizeClass]. + * + * So for a table of screen widths the converter will upscale or downscale dimensions between size classes + * proprtionally to the ratios of screen width associated with size classes. + */ +- (id)initWithTargetSizeClass:(NSString *)targetSizeClass dimensions:(NSDictionary *)dimensions; + +- (id)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END + diff --git a/Sources/MMMCommonUIObjC/MMMStylesheet.m b/Sources/MMMCommonUIObjC/MMMStylesheet.m new file mode 100644 index 0000000..8424c1a --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMStylesheet.m @@ -0,0 +1,316 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMStylesheet.h" + +#import "MMMCommonUI.h" +#import "MMMLayout.h" + +NSString * const MMMSizeClassic = @"classic"; +NSString * const MMMSize6 = @"6"; +NSString * const MMMSize6Plus = @"6plus"; +NSString * const MMMSizePad = @"pad"; +NSString * const MMMSizeRest = @"rest"; + +// +// +// +@implementation MMMStylesheetScaleConverter { + NSDictionary *_scales; +} + +- (id)initWithScales:(NSDictionary *)scales { + if (self = [super init]) { + _scales = scales; + } + return self; +} + +- (id)initWithTargetSizeClass:(NSString *)targetSizeClass dimensions:(NSDictionary *)dimensions { + + NSNumber *targetDimension = dimensions[targetSizeClass]; + if (![targetDimension isKindOfClass:[NSNumber class]]) { + NSAssert(NO, @"No dimension provided for the target size class '%@'", targetSizeClass); + return nil; + } + + NSMutableDictionary *scales = [[NSMutableDictionary alloc] initWithCapacity:[dimensions count]]; + for (NSString *sizeClass in [dimensions keyEnumerator]) { + + NSNumber *dimension = dimensions[sizeClass]; + if (![dimension isKindOfClass:[NSNumber class]]) { + NSAssert(NO, @"A dimension for size class '%@' has to be a number", sizeClass); + return nil; + } + + scales[sizeClass] = @([targetDimension floatValue] / [dimension floatValue]); + } + + return [self initWithScales:scales]; +} + +- (CGFloat)convertFloat:(CGFloat)value fromSizeClass:(NSString *)sourceSizeClass { + + NSNumber *scale = _scales[sourceSizeClass]; + if (!scale) + scale = _scales[MMMSizeRest]; + + if (!scale) { + NSAssert(NO, @"No scale for size class %@ provided nor there is something for MMMSizeRest", sourceSizeClass); + return value; + } + + return roundf(value * [scale floatValue]); +} + +@end + +// +// +// +@implementation MMMStylesheet { + + // The screen width associated with the current size class (not the actual screen width!). + CGFloat _screenWidth; + + // Screen widths associated with all the supported size classes. + NSDictionary *_widthForSizeClass; + + // Size classes in the order of preference to fallback on. + NSArray *_nearestSizeClasses; + + // Cached result of `dictionaryWithPaddings`. + NSDictionary *_dictionaryWithPaddings; + + MMMStylesheetScaleConverter *_widthBasedConverter; +} + +- (id)init { + + if (self = [super init]) { + + // Every size class has a width associated with it. It can be used to automatically scale certain elements sometimes. + _widthForSizeClass = @{ + MMMSizeClassic : @320, + MMMSize6 : @375, + MMMSize6Plus : @414, + MMMSizePad : @768 + }; + + // We want to roughly know how big the device is, i.e. what's our "size class". + CGSize screenSize = [UIScreen mainScreen].bounds.size; + _screenWidth = MIN(screenSize.width, screenSize.height); + if (_screenWidth <= 320) { + _currentSizeClass = MMMSizeClassic; + _nearestSizeClasses = @[ MMMSize6, MMMSize6Plus, MMMSizePad ]; + } else if (_screenWidth <= 375) { + _currentSizeClass = MMMSize6; + _nearestSizeClasses = @[ MMMSizeClassic, MMMSize6Plus, MMMSizePad ]; + } else if (_screenWidth <= 414) { + _currentSizeClass = MMMSize6Plus; + _nearestSizeClasses = @[ MMMSize6, MMMSizeClassic, MMMSizePad ]; + } else { + _currentSizeClass = MMMSizePad; + _nearestSizeClasses = @[ MMMSize6Plus, MMMSize6, MMMSizeClassic ]; + } + + // We want the width roughly associated with the current size class, not the actual width. + _screenWidth = [_widthForSizeClass[_currentSizeClass] floatValue]; + + _widthBasedConverter = [[MMMStylesheetScaleConverter alloc] + initWithTargetSizeClass:_currentSizeClass + dimensions:_widthForSizeClass + ]; + } + + return self; +} + +#pragma mark - + +- (id)valueForCurrentSizeClass:(NSDictionary *)sizeClassToValue { + + id result = sizeClassToValue[_currentSizeClass]; + if (result) + return result; + + result = sizeClassToValue[MMMSizeRest]; + if (result) + return result; + + for (id sizeClass in _nearestSizeClasses) { + result = sizeClassToValue[sizeClass]; + if (result) + return result; + } + + NSAssert( + NO, + @"No value for size class '%@' and cannot even fallback to something meaningful in %@", + _currentSizeClass, sizeClassToValue + ); + + return nil; +} + +- (CGFloat)floatForCurrentSizeClass:(NSDictionary *)sizes { + NSNumber *result = [self valueForCurrentSizeClass:sizes]; + NSAssert([result isKindOfClass:[NSNumber class]], @""); + return [result floatValue]; +} + +#pragma mark - + +- (CGFloat)extrapolatedFloatForCurrentSizeClass:(NSDictionary *)sizes { + + NSAssert(sizes.count <= 2, @"We don't support more than 2 values in the sizes array for %s", sel_getName(_cmd)); + NSAssert(sizes[MMMSizeRest] == nil, @"MMMSizeRest cannot be used with %s", sel_getName(_cmd)); + + // Return asap if there is a precise value available. + if (sizes[_currentSizeClass]) { + return [sizes[_currentSizeClass] floatValue]; + } + + NSArray *sizeClasses = [sizes allKeys]; + + if (sizes.count == 0) { + + NSAssert(NO, @"No values in the sizes array for %s", sel_getName(_cmd)); + return 0; + + } else if (sizes.count == 1) { + + id class1 = sizeClasses[0]; + return [self.widthBasedConverter convertFloat:[sizes[class1] floatValue] fromSizeClass:class1]; + + } else if (sizes.count == 2) { + + // We have two values, let's scale them for the current size class individually first. + id class1 = sizeClasses[0]; + CGFloat value1 = [self.widthBasedConverter convertFloat:[sizes[class1] floatValue] fromSizeClass:class1]; + + id class2 = sizeClasses[1]; + CGFloat value2 = [self.widthBasedConverter convertFloat:[sizes[class2] floatValue] fromSizeClass:class2]; + + // Then find out where the current size class is relative the other two. + CGFloat width1 = [_widthForSizeClass[class1] floatValue]; + CGFloat width2 = [_widthForSizeClass[class2] floatValue]; + CGFloat t = (_screenWidth - width1) / (width2 - width1); + + // And now blend the individual values proportionally to their closiness to the current size class. + return roundf(value1 * (1 - t) + value2 * t); + + } else { + NSAssert(NO, @""); + return 0; + } +} + +- (CGFloat)extrapolatedFloatForCurrentSizeClass:(NSDictionary *)sizes except:(NSDictionary *)exceptions { + + NSAssert(exceptions[MMMSizeRest] == nil, @"MMMSizeRest cannot be used with %s", sel_getName(_cmd)); + + // If current size class in the exceptions, then use the corresponding value. + NSNumber *exception = exceptions[_currentSizeClass]; + if (exception != nil) { + return [exception floatValue]; + } + + // Otherwise trying to extrapolate. + return [self extrapolatedFloatForCurrentSizeClass:sizes]; +} + +// +// These should be overriden in the actual stylsheet, but let's provide some defaults just in case +// + +#pragma mark - Paddings + +const CGFloat MMMStylesheetPaddingMultiplier = M_SQRT2; + +- (CGFloat)extraExtraSmallPadding { + return MMMPixelRound(self.normalPadding / (MMMStylesheetPaddingMultiplier * MMMStylesheetPaddingMultiplier * MMMStylesheetPaddingMultiplier)); +} + +- (CGFloat)extraSmallPadding { + return MMMPixelRound(self.normalPadding / (MMMStylesheetPaddingMultiplier * MMMStylesheetPaddingMultiplier)); +} + +- (CGFloat)smallPadding { + return MMMPixelRound(self.normalPadding / MMMStylesheetPaddingMultiplier); +} + +- (CGFloat)normalPadding { + // Normally 10 is only a round number in a decimal system and does not make a good padding constant, + // but it's 1/32nd of the screen width on the classic iPhone, which makes it a great match for 32 or 16 column grids. + return 10; +} + +- (CGFloat)largePadding { + return MMMPixelRound(self.normalPadding * MMMStylesheetPaddingMultiplier); +} + +- (CGFloat)extraLargePadding { + return MMMPixelRound(self.normalPadding * (MMMStylesheetPaddingMultiplier * MMMStylesheetPaddingMultiplier)); +} + +- (CGFloat)paddingFromRelativePadding:(CGFloat)padding { + + if (padding == 0) { + return 0; + } else if (padding <= 0.125) { + return self.extraExtraSmallPadding; + } else if (padding <= 0.25) { + return self.extraSmallPadding; + } else if (padding <= 0.5) { + return self.smallPadding; + } else if (padding <= 1) { + return self.normalPadding; + } else if (padding <= 2) { + return self.largePadding; + } else if (padding <= 4) { + return self.extraLargePadding; + } else { + NSAssert(NO, @"Invalid value for relative padding: %.f", padding); + return self.extraLargePadding; + } +} + +- (NSDictionary *)paddingDictionaryFromRelativeInsets:(UIEdgeInsets)insets { + return MMMDictionaryFromUIEdgeInsets(@"padding", [self insetsFromRelativeInsets:insets]); +} + +- (NSDictionary *)dictionaryFromRelativeInsets:(UIEdgeInsets)insets keyPrefix:(NSString *)keyPrefix { + return MMMDictionaryFromUIEdgeInsets(keyPrefix, [self insetsFromRelativeInsets:insets]); +} + +- (NSDictionary *)dictionaryWithPaddings { + + if (!_dictionaryWithPaddings) { + _dictionaryWithPaddings = @{ + @"extraExtraSmallPadding" : @(self.extraExtraSmallPadding), + @"extraSmallPadding" : @(self.extraSmallPadding), + @"smallPadding" : @(self.smallPadding), + @"normalPadding" : @(self.normalPadding), + @"largePadding" : @(self.largePadding), + @"extraLargePadding" : @(self.extraLargePadding) + }; + } + + return _dictionaryWithPaddings; +} + +- (UIEdgeInsets)insetsFromRelativeInsets:(UIEdgeInsets)insets { + return UIEdgeInsetsMake( + [self paddingFromRelativePadding:insets.top], + [self paddingFromRelativePadding:insets.left], + [self paddingFromRelativePadding:insets.bottom], + [self paddingFromRelativePadding:insets.right] + ); +} + +#pragma mark - + +@end diff --git a/Sources/MMMCommonUIObjC/MMMTableView.h b/Sources/MMMCommonUIObjC/MMMTableView.h new file mode 100644 index 0000000..5bb8fa1 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMTableView.h @@ -0,0 +1,30 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +#import "MMMScrollViewShadows.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * A table view supporting top and bottom shadows. + */ +@interface MMMTableView : UITableView + +/** */ +- (id)initWithSettings:(MMMScrollViewShadowsSettings *)settings style:(UITableViewStyle)style; + +/** Note that UITableViewStylePlain is used. */ +- (id)initWithSettings:(MMMScrollViewShadowsSettings *)settings; + +- (instancetype)initWithFrame:(CGRect)frame style:(UITableViewStyle)style NS_UNAVAILABLE; +- (instancetype)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMTableView.m b/Sources/MMMCommonUIObjC/MMMTableView.m new file mode 100644 index 0000000..f919a46 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMTableView.m @@ -0,0 +1,33 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMTableView.h" + +@implementation MMMTableView { + MMMScrollViewShadows *_shadows; +} + +- (id)initWithSettings:(MMMScrollViewShadowsSettings *)settings style:(UITableViewStyle)style { + + if (self = [super initWithFrame:CGRectMake(0, 0, 320, 400) style:style]) { + + self.translatesAutoresizingMaskIntoConstraints = NO; + + _shadows = [[MMMScrollViewShadows alloc] initWithScrollView:self settings:settings]; + } + + return self; +} + +- (id)initWithSettings:(MMMScrollViewShadowsSettings *)settings { + return [self initWithSettings:settings style:UITableViewStylePlain]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + [_shadows layoutSubviews]; +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMTableViewCell.h b/Sources/MMMCommonUIObjC/MMMTableViewCell.h new file mode 100644 index 0000000..65219b7 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMTableViewCell.h @@ -0,0 +1,21 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import + +/** + * A base for table view cells redeclareing the designated initializer into the one we typically use, + * so subclasses do not have to. + */ +@interface MMMTableViewCell : UITableViewCell + +- (id)initWithReuseIdentifier:(NSString *)reuseIdentifier NS_DESIGNATED_INITIALIZER; + +- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (id)init NS_UNAVAILABLE; + +@end diff --git a/Sources/MMMCommonUIObjC/MMMTableViewCell.m b/Sources/MMMCommonUIObjC/MMMTableViewCell.m new file mode 100644 index 0000000..bde5e14 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMTableViewCell.m @@ -0,0 +1,16 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMTableViewCell.h" + +@implementation MMMTableViewCell + +- (id)initWithReuseIdentifier:(NSString *)reuseIdentifier { + if (self = [super initWithStyle:UITableViewCellStyleDefault reuseIdentifier:reuseIdentifier]) { + } + return self; +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMVerticalGradientView.h b/Sources/MMMCommonUIObjC/MMMVerticalGradientView.h new file mode 100644 index 0000000..66f756c --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMVerticalGradientView.h @@ -0,0 +1,28 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +@import UIKit; + +#import "MMMAnimations.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * A view displaying a gradient from top to bottom. The linearity of the gradient can be controlled. + * Can be handy for shadow overlays, etc. + */ +@interface MMMVerticalGradientView : UIView + +- (nonnull id)initWithTopColor:(UIColor *)topColor bottomColor:(UIColor *)bottomColor curve:(MMMAnimationCurve)curve NS_DESIGNATED_INITIALIZER; + +- (nonnull id)initWithTopColor:(UIColor *)topColor bottomColor:(UIColor *)bottomColor; + +- (id)init NS_UNAVAILABLE; +- (id)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (id)initWithFrame:(CGRect)frame NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMVerticalGradientView.m b/Sources/MMMCommonUIObjC/MMMVerticalGradientView.m new file mode 100644 index 0000000..825a0ea --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMVerticalGradientView.m @@ -0,0 +1,69 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMVerticalGradientView.h" + +@implementation MMMVerticalGradientView { + UIColor *_topColor; + UIColor *_bottomColor; + MMMAnimationCurve _curve; +} + +- (id)initWithTopColor:(UIColor *)topColor bottomColor:(UIColor *)bottomColor { + return [self initWithTopColor:topColor bottomColor:bottomColor curve:MMMAnimationCurveLinear]; +} + +- (id)initWithTopColor:(UIColor *)topColor bottomColor:(UIColor *)bottomColor curve:(MMMAnimationCurve)curve { + + if (self = [super initWithFrame:CGRectZero]) { + + _curve = curve; + + _topColor = topColor; + _bottomColor = bottomColor; + + // We are going to redraw when frame changes. Perhaps a single gradient can be scaled well without rendering. + self.contentMode = UIViewContentModeRedraw; + + self.translatesAutoresizingMaskIntoConstraints = NO; + + self.opaque = NO; + self.userInteractionEnabled = NO; + } + + return self; +} + +- (void)drawRect:(CGRect)rect { + + CGRect b = self.bounds; + + CGContextRef c = UIGraphicsGetCurrentContext(); + + NSInteger numberOfSteps = (_curve == MMMAnimationCurveLinear) ? 2 : MIN(1 + CGRectGetHeight(b) / 5, 50); + + NSMutableArray *colors = [[NSMutableArray alloc] initWithCapacity:numberOfSteps]; + CGFloat *steps = alloca(numberOfSteps * sizeof(CGFloat)); + for (NSInteger i = 0; i < numberOfSteps; i++) { + steps[i] = (CGFloat)i / (numberOfSteps - 1); + UIColor *c = [MMMAnimation colorFrom:_topColor to:_bottomColor time:[MMMAnimation curvedTimeForTime:steps[i] curve:_curve]]; + [colors addObject:(id)c.CGColor]; + } + + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + CGGradientRef gradient = CGGradientCreateWithColors(colorSpace, (__bridge CFArrayRef)colors, steps); + CGColorSpaceRelease(colorSpace); + + CGContextDrawLinearGradient( + c, + gradient, + b.origin, + CGPointMake(b.origin.x, b.origin.y + b.size.height), + 0 + ); + CGGradientRelease(gradient); +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMViewWrappingCell.h b/Sources/MMMCommonUIObjC/MMMViewWrappingCell.h new file mode 100644 index 0000000..9a1cebb --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMViewWrappingCell.h @@ -0,0 +1,30 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMTableViewCell.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * A simple table view cell wrapping the given view. + * + * This is handy when you have a view already and just want to show it as one more cell. + * + * The view being wrapped should support Auto Layout and inflate its height properly. The cell has its `selectionStyle` + * set to `UITableViewCellSelectionStyleNone` as these kind of cells typically do not appear selected. + */ +@interface MMMViewWrappingCell : MMMTableViewCell + +/** The view this cell wraps. It is added into the `contentView` and is laid out to fully fill it. */ +@property (nonatomic, readonly) ViewType wrappedView; + +- (id)initWithView:(ViewType)view reuseIdentifier:(NSString *)reuseIdentifier; +- (id)initWithView:(ViewType)view reuseIdentifier:(NSString *)reuseIdentifier inset:(UIEdgeInsets)inset; + +- (id)initWithReuseIdentifier:(NSString *)reuseIdentifier NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/MMMCommonUIObjC/MMMViewWrappingCell.m b/Sources/MMMCommonUIObjC/MMMViewWrappingCell.m new file mode 100644 index 0000000..b9f3077 --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMViewWrappingCell.m @@ -0,0 +1,45 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMViewWrappingCell.h" + +#import "MMMLayout.h" + +@implementation MMMViewWrappingCell + +- (id)initWithView:(UIView *)view reuseIdentifier:(NSString *)reuseIdentifier inset:(UIEdgeInsets)inset { + + if (self = [super initWithReuseIdentifier:reuseIdentifier]) { + + NSAssert([view isKindOfClass:[UIView class]], @""); + + self.selectionStyle = UITableViewCellSelectionStyleNone; + + self.opaque = view.opaque; + self.backgroundColor = view.backgroundColor; + + _wrappedView = view; + [(UIView *)_wrappedView setTranslatesAutoresizingMaskIntoConstraints:NO]; + [self.contentView addSubview:_wrappedView]; + + [self.contentView mmm_setHuggingHorizontal:UILayoutPriorityDefaultLow vertical:UILayoutPriorityRequired]; + [self.contentView mmm_setCompressionResistanceHorizontal:UILayoutPriorityDefaultLow vertical:UILayoutPriorityRequired]; + + [self.contentView + mmm_addConstraintsAligningView:_wrappedView + horizontally:MMMLayoutHorizontalAlignmentFill + vertically:MMMLayoutVerticalAlignmentFill + insets:inset + ]; + } + + return self; +} + +- (id)initWithView:(UIView *)view reuseIdentifier:(NSString *)reuseIdentifier { + return [self initWithView:view reuseIdentifier:reuseIdentifier inset:UIEdgeInsetsZero]; +} + +@end diff --git a/Sources/MMMCommonUIObjC/MMMWebView.h b/Sources/MMMCommonUIObjC/MMMWebView.h new file mode 100644 index 0000000..db991fd --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMWebView.h @@ -0,0 +1,24 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import +#import + +#import "MMMScrollViewShadows.h" + +/** + * Web view supporting top & bottom shadows. + */ +@interface MMMWebView : WKWebView + +- (instancetype)initWithSettings:(MMMScrollViewShadowsSettings *)settings; +- (instancetype)initWithSettings:(MMMScrollViewShadowsSettings *)settings configuration:(WKWebViewConfiguration *)configuration NS_DESIGNATED_INITIALIZER; + +- (instancetype)initWithCoder:(NSCoder *)aDecoder NS_UNAVAILABLE; +- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (instancetype)initWithFrame:(CGRect)frame configuration:(WKWebViewConfiguration *)configuration NS_UNAVAILABLE; +- (instancetype)init NS_UNAVAILABLE; + +@end diff --git a/Sources/MMMCommonUIObjC/MMMWebView.m b/Sources/MMMCommonUIObjC/MMMWebView.m new file mode 100644 index 0000000..c1b1bae --- /dev/null +++ b/Sources/MMMCommonUIObjC/MMMWebView.m @@ -0,0 +1,67 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +#import "MMMWebView.h" + +@interface MMMWebViewScrollDelegate : NSObject + +- (instancetype)initWithShadows:(MMMScrollViewShadows *)shadows NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +@end + +@implementation MMMWebViewScrollDelegate { + MMMScrollViewShadows * __weak _shadows; +} + +- (instancetype)initWithShadows:(MMMScrollViewShadows *)shadows { + self = [super init]; + + if (self) { + _shadows = shadows; + } + + return self; +} + +#pragma mark - UIScrollViewDelegate + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView { + [_shadows layoutSubviews]; +} + +@end + +@implementation MMMWebView { + MMMScrollViewShadows *_shadows; + MMMWebViewScrollDelegate *_delegate; +} + +- (instancetype)initWithSettings:(MMMScrollViewShadowsSettings *)settings +{ + return [self initWithSettings:settings configuration:[[WKWebViewConfiguration alloc] init]]; +} + +- (instancetype)initWithSettings:(MMMScrollViewShadowsSettings *)settings configuration:(WKWebViewConfiguration *)configuration +{ + self = [super initWithFrame:CGRectZero configuration:configuration]; + + if (self) { + _shadows = [[MMMScrollViewShadows alloc] initWithScrollView:self.scrollView settings:settings]; + _delegate = [[MMMWebViewScrollDelegate alloc] initWithShadows:_shadows]; + + self.translatesAutoresizingMaskIntoConstraints = NO; + self.scrollView.delegate = _delegate; + } + + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + [_shadows layoutSubviews]; +} + +@end diff --git a/Tests/MMMCommonUI.swift b/Tests/MMMCommonUI.swift new file mode 100644 index 0000000..4d86c51 --- /dev/null +++ b/Tests/MMMCommonUI.swift @@ -0,0 +1,10 @@ +// +// MMMCommonUI. Part of MMMTemple. +// Copyright (C) 2016-2020 MediaMonks. All rights reserved. +// + +import XCTest +@testable import MMMCommonUI + +class MMMCommonUITestCase: XCTestCase { +}