ごんれのラボ

iOS、Android、Adobe系ソフトの自動化スクリプトのことを書き連ねています。

UIKit版のOutlineのサンプルを実装してハマった

概要

WWDC20のセッション動画を観て「へー、なるほど、わかったわかった」と思ってたけど、業務でUICollectionView Compositional Layoutを使ったときに結構ハマって、これはOutlineも触っておいたほうがいいなって思ったので、簡単なサンプルを実装してみました。

ソースコード

GitHubにあげています。

github.com

実装したもの

この記事のサンプルは OutlineListSample を参照してください。 サンプルとして、セクションなしの SimpleOutlineListと、セクションありの OutlineListWithSection の2種類を実装しました。

どちらも表示するModelは同じものを使用して、Listに反映する型を変えています。

Modelを階層表示したものが以下です。
詳細は後述しますが、セクションあり版は 動物食べ物 をセクションヘッダーとして表示しています。

動物
├─ 犬
│     ├─ 柴犬
│     ├─ ヨークシャテリア
│     └─ ミニチュアシュナウザー
└─ 猫
       ├─ アメリカンショートヘア
       ├─ ノルウェージャンフォレストキャット
       └─ 三毛猫
食べ物
├─ 肉
│     ├─ 豚肉
│     ├─ 牛肉
│     └─ 馬肉
└─ 野菜
       ├─ セロリ
       ├─ ブロッコリー
       └─ トマト

SimpleOutlineList

Outlineを展開した状態のスクリーンショットです。

このあとに紹介する OutlineListWithSection を先に実装していたので、コードを流用しつつ、DataSourceとSnapshotのつなぎこみの部分を見直しただけですみました。

実装の説明

実務ではViewModelに値をもたせることが多いので、このサンプルでもViewModelを用意しました。

struct SimpleOutlineListViewModel {
    let items: [Item]

    init() {
        var items: [Item] = []
        Kind.allCases.forEach { kind in
            switch kind {
            case .animal:
                let headers = Animal.allCases.map { animal -> Item in
                    return Item(title: animal.description, children: animal.names.map { Item(title: $0, children: []) })
                }
                items.append(Item(title: kind.description, children: headers))
            case .food:
                let headers = Food.allCases.map { food -> Item in
                    return Item(title: food.description, children: food.names.map { Item(title: $0, children: []) })
                }
                items.append(Item(title: kind.description, children: headers))
            }
        }
        self.items = items
    }
}

extension SimpleOutlineListViewModel {
    enum Section: Hashable {
        case main
    }

    struct Item: Hashable {
        private let identifier = UUID()
        let title: String
        let children: [Item]
        var hasChildren: Bool {
            return !children.isEmpty
        }
    }
}

Hashable に準拠した Item という型を用意して、init()enumで表現したModelを前述した構造になるように多次元配列を生成しています。
個人的にはこの構造は好きではなくて、階層ごとに別の型を用意したいんですよね。
このサンプルではシンプルなプロパティしかもってないけど、階層ごとに必要なプロパティが違う場合は、その階層では不要なプロパティに対して値を渡す必要があって、きれいじゃないな、と。
optionalにしてinit時に初期値として nil を渡せばいいんだろうけど、やっぱなんだかなぁって思ってしまいます。
AnyHashable にするぐらいならこれでもいいかな…。

続いて、ViewControllerは以下です。

import UIKit

final class SimpleOutlineListViewController: UIViewController {
    typealias Section = SimpleOutlineListViewModel.Section
    typealias Item = SimpleOutlineListViewModel.Item

    @IBOutlet var collectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
    private let viewModel = SimpleOutlineListViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "SimpleOutlineList"
        collectionView.collectionViewLayout = createLayout()
        configureDataSource()
        applyInitialSnapshots()
    }
}

extension SimpleOutlineListViewController {
    private func createLayout() -> UICollectionViewLayout {
        let config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
        return UICollectionViewCompositionalLayout.list(using: config)
    }

    private func createHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.outlineDisclosure(options: .init(style: .header))]
        }
    }

    private func createCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.reorder()]
        }
    }

    private func configureDataSource() {
        let headerCellRegistration = createHeaderCellRegistration()
        let cellRegistration = createCellRegistration()

        dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
            if item.hasChildren {
                return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: item.title)
            }
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item.title)
        }
    }

    private func applyInitialSnapshots() {
        var snapshot = NSDiffableDataSourceSectionSnapshot<Item>()

        func addItems(_ menuItems: [Item], to parent: Item?) {
            snapshot.append(menuItems, to: parent)
            for menuItem in menuItems where menuItem.hasChildren {
                addItems(menuItem.children, to: menuItem)
            }
        }

        addItems(viewModel.items, to: nil)
        dataSource.apply(snapshot, to: .main, animatingDifferences: false)
    }
}

configureDataSource()hasChildrentrue のときは子要素を持っており開閉可能にするので、 cell.accessories = [.outlineDisclosure(options: .init(style: .header))] としています。
また、DataSource生成時に AnyHashable を使わずに済み、すっきりしますね。

applyInitialSnapshots() はdataSourceにSnapshotを適用するメソッドです。
InnerFunctionとして定義した addItems(_ menuItems: [Item], to parent: Item?) はSnapchotにappendする再帰処理です。
Appleのサンプルコードから拝借しました。

OutlineListWithSection

Outlineを展開した状態のスクリーンショットです。

SimpleOutlineList ではすべてのItemを親子構造にしましたが、OutlineListWithSectionではルートのItemをセクションヘッダーにしました。

このサンプルは一度実装したあとに構造を見直しており、旧版のコードも紹介しつつ、なぜ作り直したかを説明します。

旧版の実装の説明

SimpleOutlineList と同様にViewModelを用意しました。

struct ViewModel {
    private let items: [AnyHashable]

    init() {
        var items: [AnyHashable] = []
        Section.allCases.forEach { section in
            switch section {
            case .animal:
                Animal.allCases.forEach { animal in
                    items.append(Header(title: animal.description,
                            kind: section,
                            category: animal.rawValue))
                    animal.names.forEach {
                        items.append(Children(title: $0, kind: section, category: animal.rawValue))
                    }
                }
            case .food:
                Food.allCases.forEach { food in
                    items.append(Header(title: food.description,
                            kind: section,
                            category: food.rawValue))
                    food.names.forEach {
                        items.append(Children(title: $0, kind: section, category: food.rawValue))
                    }
                }
            }
        }
        self.items = items
    }

    func getHeader(kind: Section, category: String) -> AnyHashable? {
        return items.first { item in
            guard let item = item as? Header else { return false }
            if item.kind == kind, item.category == category {
                return true
            }
            return false
        }
    }

    func getChildren(kind: Section, category: String) -> [AnyHashable]? {
        return items.filter { item in
            guard let item = item as? Children else { return false }
            if item.kind == kind, item.category == category {
                return true
            }
            return false
        }
    }
}

extension ViewModel {
    enum Section: Int, Hashable, CaseIterable {
        case animal, food

        var description: String {
            switch self {
            case .animal:
                return "動物"
            case .food:
                return "食べ物"
            }
        }
    }

    struct Header: Hashable {
        private let identifier = UUID()
        let title: String
        let kind: Section
        let category: String
    }

    struct Children: Hashable {
        private let identifier = UUID()
        let title: String
        let kind: Section
        let category: String
    }

    enum Animal: String, CaseIterable {
        case dog, cat

        var description: String {
            switch self {
            case .dog:
                return "犬"
            case .cat:
                return "猫"
            }
        }

        var names: [String] {
            switch self {
            case .dog:
                return [" 柴犬", "ヨークシャテリア", "ミニチュアシュナウザー"]
            case .cat:
                return ["アメリカンショートヘア", "ノルウェージャンフォレストキャット", "三毛猫"]
            }
        }
    }

    enum Food: String, CaseIterable {
        case meat, vegetable

        var description: String {
            switch self {
            case .meat:
                return "肉"
            case .vegetable:
                return "野菜"
            }
        }

        var names: [String] {
            switch self {
            case .meat:
                return ["豚肉", "牛肉", "馬肉"]
            case .vegetable:
                return ["セロリ", "ブロッコリー", "トマト"]
            }
        }
    }
}

DataSourceの型として、セクションの Section、開閉可能なセルの Header、通常のセルのChildren という3つの型を用意しました。
HeaderChildren をひとつの配列に詰め込むので、 itemsAnyHashable の配列になっています。
getHeader(kind: Section, category: String) -> AnyHashable?getChildren(kind: Section, category: String) -> [AnyHashable]?items から特定の型のdataを取り出すメソッドです。
我ながらなかなか苦しい設計になってしまって、書きながら「罪深い…これは罪深い…」とつぶやいてました。
サンプルなので動けばいいとはいえ、もうちょっとうまく書けるようになりたい…。

続いて、ViewControllerです。

import UIKit

final class ViewController: UIViewController {
    typealias Section = ViewModel.Section

    @IBOutlet var collectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<Section, AnyHashable>!
    private let viewModel = ViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "Outline List Sample"
        collectionView.collectionViewLayout = createLayout()
        configureDataSource()
        applyInitialSnapshots()
    }
}

extension ViewController {
    private func createLayout() -> UICollectionViewLayout {
        let sectionProvider = { (_: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            var config = UICollectionLayoutListConfiguration(appearance: .sidebar)
            config.headerMode = .supplementary
            let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment)
            section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 10)
            return section
        }
        return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
    }

    private func createHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.outlineDisclosure(options: .init(style: .header))]
        }
    }

    private func createCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.reorder()]
        }
    }

    private func createSectionHeaderRegistration() -> UICollectionView.SupplementaryRegistration<UICollectionViewListCell> {
        return UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: "Header") { (headerView, _, indexPath: IndexPath) in
            guard let section = Section(rawValue: indexPath.section) else {
                return
            }
            var configuration = headerView.defaultContentConfiguration()
            configuration.text = section.description

            configuration.textProperties.font = .boldSystemFont(ofSize: 16)
            configuration.textProperties.color = .systemBlue
            configuration.directionalLayoutMargins = .init(top: 20.0, leading: 0.0, bottom: 10.0, trailing: 0.0)

            headerView.contentConfiguration = configuration
        }
    }

    private func configureDataSource() {
        let headerCellRegistration = createHeaderCellRegistration()
        let cellRegistration = createCellRegistration()

        dataSource = UICollectionViewDiffableDataSource<Section, AnyHashable>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
            switch item {
            case let model as ViewModel.Header:
                return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title)
            case let model as ViewModel.Children:
                return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title)
            default:
                fatalError()
            }
        }

        dataSource.supplementaryViewProvider = { (_, _, indexPath) in
            return self.collectionView.dequeueConfiguredReusableSupplementary(using: self.createSectionHeaderRegistration(), for: indexPath)
        }
    }

    private func applyInitialSnapshots() {
        let sections = Section.allCases
        var snapshot = NSDiffableDataSourceSnapshot<Section, AnyHashable>()
        snapshot.appendSections(sections)
        dataSource.apply(snapshot, animatingDifferences: false)

        Section.allCases.forEach { section in
            var outlineSnapshot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
            switch section {
            case .animal:
                ViewModel.Animal.allCases.forEach { animal in
                    guard let header = viewModel.getHeader(kind: section, category: animal.rawValue),
                          let children = viewModel.getChildren(kind: section, category: animal.rawValue) else {
                        return
                    }
                    outlineSnapshot.append([header])
                    outlineSnapshot.append(children, to: header)
                }
            case .food:
                ViewModel.Food.allCases.forEach { food in
                    guard let header = viewModel.getHeader(kind: section, category: food.rawValue),
                          let children = viewModel.getChildren(kind: section, category: food.rawValue) else {
                        return
                    }
                    outlineSnapshot.append([header])
                    outlineSnapshot.append(children, to: header)
                }
            }
            dataSource.apply(outlineSnapshot, to: section, animatingDifferences: false)
        }
    }
}

Compositional Layoutの話になりますが、セクションヘッダーは dataSource.supplementaryViewProviderクロージャで設定していて、Viewは createSectionHeaderRegistration() で定義しています。
Cellと同じような感じで実装できるので、いいですね。

configureDataSource() でDataSourceを生成するメソッドです。
型が AnyHashable になっているので、switch で型を判別する処理をいれています。

switch item {
case let model as ViewModel.Header:
    return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title)
case let model as ViewModel.Children:
    return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title)
default:
    fatalError()
}

SimpleOutlineList の節でも書きましたが、この処理が好きじゃないんですよね。
Hashable に準拠した型ならなんでも渡せてしまうので、defaultが必要になるのが…。

applyInitialSnapshots()はDataSourceにSnapshotを適用するメソッドです。
Section ごとに HeaderChildren が親子関係になるようにDataを詰めています。

Section.allCases.forEach { section in
    var outlineSnapshot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
    switch section {
    case .animal:
        ViewModel.Animal.allCases.forEach { animal in
            guard let header = viewModel.getHeader(kind: section, category: animal.rawValue),
                    let children = viewModel.getChildren(kind: section, category: animal.rawValue) else {
                return
            }
            outlineSnapshot.append([header])
            outlineSnapshot.append(children, to: header)
        }
    case .food:
        ViewModel.Food.allCases.forEach { food in
            guard let header = viewModel.getHeader(kind: section, category: food.rawValue),
                    let children = viewModel.getChildren(kind: section, category: food.rawValue) else {
                return
            }
            outlineSnapshot.append([header])
            outlineSnapshot.append(children, to: header)
        }
    }
}

以上が旧版です。

新版の実装の説明

AnyHashable を使わずに済む方法を模索したものが新版です。
GitHubのmainブランチにあげているものになります。

ViewModelは以下です。

import Foundation

struct OutlineListWithSectionViewModel {
    let items: [SectionHeader]

    init() {
        var items: [SectionHeader] = []
        Kind.allCases.forEach { kind in
            switch kind {
            case .animal:
                let headers = Animal.allCases.map { animal -> Header in
                    return Header(title: animal.description, children: animal.names.map { Children(title: $0) })
                }
                items.append(SectionHeader(title: kind.description, headers: headers))
            case .food:
                let headers = Food.allCases.map { food -> Header in
                    return Header(title: food.description, children: food.names.map { Children(title: $0) })
                }
                items.append(SectionHeader(title: kind.description, headers: headers))
            }
        }
        self.items = items
    }
}

extension OutlineListWithSectionViewModel {
    enum ListItem: Hashable {
        case sectionHeader(SectionHeader)
        case header(Header)
        case children(Children)
    }

    struct SectionHeader: Hashable {
        private let identifier = UUID()
        let title: String
        let headers: [Header]
    }

    struct Header: Hashable {
        private let identifier = UUID()
        let title: String
        let children: [Children]
    }

    struct Children: Hashable {
        private let identifier = UUID()
        let title: String
    }
}

新版では itemsSectionHeader の配列にしました。
DataSourceの型として、セクションの SectionHeader、開閉可能なセルの Header、通常のセルのChildren という3つの型を用意し、SectionHeader が子要素となる Header の配列をもち、Header が子要素となる Children の配列をもっています。
DataSourceからは ListItem というenumを介して、各caseがassociated valueで保持したItemを取得するようにしています。

enum ListItem: Hashable {
    case sectionHeader(SectionHeader)
    case header(Header)
    case children(Children)
}

続いて、ViewContollerは以下です。

import UIKit

final class OutlineListWithSectionViewController: UIViewController {
    typealias ListItem = OutlineListWithSectionViewModel.ListItem
    typealias SectionHeader = OutlineListWithSectionViewModel.SectionHeader
    typealias Header = OutlineListWithSectionViewModel.Header
    typealias Children = OutlineListWithSectionViewModel.Children

    @IBOutlet var collectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<SectionHeader, ListItem>!
    private let viewModel = OutlineListWithSectionViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "OutlineListWithSection"
        collectionView.collectionViewLayout = createLayout()
        configureDataSource()
        applyInitialSnapshots()
    }
}

extension OutlineListWithSectionViewController {
    private func createLayout() -> UICollectionViewLayout {
        let sectionProvider = { (_: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            var config = UICollectionLayoutListConfiguration(appearance: .sidebar)
            config.headerMode = .firstItemInSection
            let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment)
            section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            return section
        }
        return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
    }

    private func createHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.outlineDisclosure(options: .init(style: .header))]
        }
    }

    private func createCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            let marginLeft = content.directionalLayoutMargins.leading + content.textProperties.font.pointSize
            content.directionalLayoutMargins = .init(top: 0, leading: marginLeft,  bottom: 0, trailing: 0)
            cell.contentConfiguration = content
            cell.accessories = [.reorder()]
        }
    }

    private func createSectionHeaderRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            content.textProperties.font = .boldSystemFont(ofSize: 16)
            content.textProperties.color = .systemBlue
            cell.contentConfiguration = content
        }
    }

    private func configureDataSource() {
        let sectionHeaderRegistration = createSectionHeaderRegistration()
        let headerCellRegistration = createHeaderCellRegistration()
        let cellRegistration = createCellRegistration()

        dataSource = UICollectionViewDiffableDataSource<SectionHeader, ListItem>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
            switch item {
            case let .sectionHeader(model):
                return collectionView.dequeueConfiguredReusableCell(using: sectionHeaderRegistration, for: indexPath, item: model.title)
            case let .header(model):
                return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title)
            case let .children(model):
                return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title)
            }
        }
    }

    private func applyInitialSnapshots() {
        var dataSourceSnapshot = NSDiffableDataSourceSnapshot<SectionHeader, ListItem>()

        dataSourceSnapshot.appendSections(viewModel.items)
        dataSource.apply(dataSourceSnapshot)

        for sectionHeader in viewModel.items {
            var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()

            let sectionHeaderItem = ListItem.sectionHeader(sectionHeader)
            sectionSnapshot.append([sectionHeaderItem])

            sectionHeader.headers.forEach { header in
                let headerItem = ListItem.header(header)
                let childrenItems = header.children.map { ListItem.children($0) }
                sectionSnapshot.append([headerItem])
                sectionSnapshot.append(childrenItems, to: headerItem)
            }

            dataSource.apply(sectionSnapshot, to: sectionHeader, animatingDifferences: false)
        }
    }
}

以下のように、headerMode.firstItemInSection を設定すると、RootのItemをセクションとして処理してくれます。
便利ですね。

var config = UICollectionLayoutListConfiguration(appearance: .sidebar)
config.headerMode = .firstItemInSection

前述したように configureDataSource() 内の switch でDataを取り出しています。
switch でDataを振り分けるのは旧版と同様ですがdefaultが必要なくなっています。

switch item {
case let .sectionHeader(model):
    return collectionView.dequeueConfiguredReusableCell(using: sectionHeaderRegistration, for: indexPath, item: model.title)
case let .header(model):
    return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title)
case let .children(model):
    return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title)
}

これはこれで微妙な気もしますが、旧版よりは扱いやすくなったかと思います。

まとめ

案の定ハマりました。
セッション動画や記事を観るだけではなくて、簡単でもいいから自分でサンプルを実装してみないとわからないことは多いんだぁと、改めて実感しました。

おまけ

SwiftUI版のサンプルも実装しました。
同じリポジトリにあげています。
記事にするのはもう少しあとになると思うので、興味のある方はコードをみてみてください。

参考

OperationQueueに積んだOperationをキャンセルするサンプルを書いた

概要

友人からSwiftで非同期処理をキャンセルする方法を聞かれたので、OperationQueueに積んだOperationをキャンセルするサンプルを書いた。
ググって見つかる記事は古いか、枝葉が多いかしたので、実用性はないがシンプルなコードにしてみた。

ソースコード

OperationQueue sample

数年前に書いた習作のmacOSアプリをSwiftUIで書き直したら、意外とハマりどころがあって面白かった話

はじめに

本記事はSwift/Kotlin愛好会 Advent Calendar 2020の17日目の記事です。

qiita.com

数年ぶりにアドベントカレンダーに記事を書いてみたいと思って、思いついたのがSwift/Kotlin愛好会アドベントカレンダーでした。
会自体に参加したことはないので、来年は参加できるといいなと思っています。

概要

なんとなくSwiftUIでアプリのViewを書いてみたくなって、数年前にSwiftの勉強用に書いたmacOSアプリをSwiftUIで書き直してみました。
意外とハマりどころがあって面白かったので、誰かのお役に立てるかと思ってブログにまとめました。

実装環境

  • macOS 10.15.7
  • Xcode 12.2

AdobeなどBig Surでの動作に不安がありそうなアプリケーションを使用する機会が多く、Catalinaで実装しています。
最新版のSwiftUIではないので、すでに解消されているハマりどころもあるかもしれません。

ソースコード

GitHubにあげています。

github.com

RunningAppsターゲットがAppKit版、RunningAppsSwiftUIがSwiftUI版です。

書き換え対象のアプリケーション

起動しているアプリケーションを一覧表示して、行をクリックしたらそのアプリケーションがアクティブになる、いわゆるランチャー系のmacOSアプリです。
当時はSwiftUIもなかったので、AppKitで書いています。

当時の記事

www.macneko.com

構造はシンプルで、ViewControllerにNSTableViewを設置して、Cellを表示しているだけです。
アーキテクチャはMVVMを採用してはいます。

アプリケーションが起動・終了したときにViewを更新するロジックとして、Cocoa Bindingを採用しています。
なぜRxSwiftなどのライブラリを使用していないかというと、当時使ったことがなくて学習コストが高いと感じたのと、早く動くものを作らないと飽きて捨てることになるからで、深い意味はないです。

書き換えたアプリケーションのスクリーンショット

https://user-images.githubusercontent.com/5406126/102351849-74d5d500-3fea-11eb-9e21-50b18ea97b21.png

左がSwiftUI版、右がAppKit版です。
背景色がちょっと違いますが、ご愛嬌ということにしました。

書き換えにあたってハマったところ

さて、ここからが本題です。 いくつかのパートにわけて私がハマったところを紹介します。 私の知識不足が原因でSwiftUIの問題ではない部分もあるかと思いますが、そこはTwitterなどでやんわりご指摘いただけるとうれしいです。

ScrollViewでハマったところ

スクロールできなくなった

ScrollView のインジケータを表示したくないなぁと思って showsIndicatorsfalse にしたところ、スクロールできなくなりました。
いろいろ調べたのですが原因がわからず、なんとなくtrue に戻したところ、スクロールできるようになりました。
インジケータの値がスクロール可能かどうかに影響するとは…。

採用したコードは以下のような内容です。

// showsIndicatorsをfalseにするとスクロールできなくなるのでtrueにしている
ScrollView(.vertical, showsIndicators: true) {
    // 子Viewをなんか実装する
}

ウィンドウサイズを縮小したらクラッシュするようになった

AppKit版ではウィンドウサイズを一定の幅より縮めることはできず、好きな幅に拡げられるようになっていました。
SwiftUI版では少し仕様を変えて幅は子Viewの最大幅にして、伸縮できないようにすることにしました。
その試行錯誤の過程で ScrollViewframe(maxWidth: geometry.frame(in: .global).width, maxHeight: geometry.frame(in: .global).height) を設定したところ、ウィンドウサイズを指定したサイズより小さくすると「Contradictory frame constraints specified.」でクラッシュするようになってしまいました。

(この記事を書くために再現するコードを書こうと試行錯誤したのですが、クラッシュせずに普通に縮小できてしまいました。
ScrollView だけじゃなく、子Viewにも frame を設定していたので、そのあたりも影響していそうです)

調べたところ、以下の記事を見つけました。

swiftui-lab.com

この記事によると、どうやら最小サイズや理想的なサイズが最大サイズより大きくなってはいけないのに、ウィンドウを縮小したときにその制約が壊れてクラッシュしているっぽいです。
ですよねー。
そこで、 GeometryReader を使ってglobalのサイズを使ってたのをやめて minWidth だけ指定するようにしたら、サイズを変えてもクラッシュしないし、指定したいサイズより小さくできなくなりました。

さらに調べたところ、子Viewの中の一番大きなサイズにFitさせる fixedSize() の存在を知りました。
しかし、 fixedSize() を指定すると高さもFitするようになり、起動しているアプリケーションの数を増やすと行がMacの画面サイズをこえてしまって、かつスクロールできなくなってしまいました。
引数なしの場合は、幅も高さもFitさせるので、想定通りですね。
メソッドの引数を指定して fixedSize(horizontal: true, vertical: false) としたら幅は子Viewの最大幅、高さは前回終了時の高さ?で起動し、、スクロールもできるようになりました。
いろいろつまづきましたが、これで私がやりたかったことを実現できました。

採用したコードは以下のような内容です。

ScrollView(.vertical, showsIndicators: true) {
    // 子Viewをなんか実装する
}
// 子View内の最大サイズにあわせてFitする
.fixedSize(horizontal: true, vertical: false)

Buttonでハマったところ

グレーのViewが表示されて、そのViewだけしかタップできない

NSTableViewCell のように行全体をタップ範囲にすることを期待して、 Buttonlabel にViewを設定したところ、期待していたとおりにはいかず、 Button の真ん中あたりにグレーのViewが表示されただけでした。
子Viewの幅とも高さとも一致しておらず、タップ範囲もこのグレーの部分のみになっていました。

https://user-images.githubusercontent.com/5406126/102468266-a5724900-4094-11eb-9a8e-a4ef29e13861.png

画像の状態のコードは以下のような内容です。

Button(action: {
    // タップ時の処理
}) {
    AppListView(metaData: data)
}

なにかしら設定があるだろうとAppleのドキュメントを眺めていたところ、 buttonStyle の存在を知り、その中の PlainButtonStyle() を設定したところ、 label で指定したViewのサイズに沿った透明ボタンが作成されることがわかりました。
最初からドキュメント読みましょうという話ですね…。

採用したコードは以下のような内容です。

Button(action: {
    // タップ時の処理
}) {
    AppListView(metaData: data)
}
// これを指定するとボタン内のグレーのViewがなくなり、AppListViewのサイズの透明ボタンができる
.buttonStyle(PlainButtonStyle())

行の余白部分をタップしても反応しない

上述した処理でグレーのViewを消し去ることに成功したものの、今度は文字や画像はタップできるが、行の余白部分はタップできないという問題が発生しました。

この問題には結構時間を使ってしまったのですが、思いつきで Buttonforground で青を設定したら原因がわかりました。
以下の画像のように、アイコンと青い文字列のみが Button として認識されていたのでした。背景部分にはViewがないのでタップできないということですね。

https://user-images.githubusercontent.com/5406126/102470675-a2c52300-4097-11eb-80d8-056093b2da40.png

UIButton も同じ挙動になるので、理由がわかったときはかなりがっかりしました。
つい数ヶ月前にハマっているんですよね、これ…。

この問題は、 Buttonlabel に指定するView側で background() を指定して背景にViewを敷き、かつ contentShape() を指定することで、Viewの最大幅と最大高さまでタップ範囲を広げることができました。

採用したコードは以下のような内容です。

HStack {
    Image(nsImage: metaData.icon ?? NSImage())
        .resizable()
        .renderingMode(.original)
        .aspectRatio(contentMode: .fit)
        .frame(width: 36, height: 36, alignment: .center)

    VStack(alignment: .leading) {
        Text(metaData.name)
            .font(.system(size: 12, weight: .bold, design: .default))
            .padding(.bottom, 7)

        Text(metaData.versionDescription)
            .font(.system(size: 12, weight: .regular, design: .default))
    }
}
.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
// 背景に透明なViewを敷く
.background(Rectangle().foregroundColor(.clear))
// contentShapeを設定する
.contentShape(Rectangle())

ForEachでハマったところ

各行の間に余分なpaddingが設定されてしまう

このアプリでは ScrollView の中で ForEach を使用して行となる各Viewを生成するようにしたのですが、各Viewの間に私が設定していない余分なpaddingが設定されてしまう問題が生じました。
ScrollViewButtonDivider のすべてに padding(0) を設定しても解消しなかったので、どこかで私の関知していないViewが生成されているようです。

調べたところ、以下の記事を見つけました。

www.reddit.com

記事によると、 ForEach でViewを生成すると内包したViewの間にpaddingが設定されてしまうようです。
この問題は、 ScrollViewForEach の間に VStack(spacing: 0) を追加することで、paddingを消すことができました。

採用したコードは以下のような内容です。

ScrollView(.vertical, showsIndicators: true) {
    // ForEachで作られたView間にpaddingが設定されてしまうので、↓のVStack(spacing: 0)が必要
    // via https://www.reddit.com/r/SwiftUI/comments/e607z3/swiftui_scrollview_foreach_padding_weird/
    // 行の揃えもVStackで指定する
    VStack(alignment: .leading, spacing: 0) {
        ForEach(viewModel.metaData) { data in
            // 子Viewをなんか実装する
        }
    }
}
.fixedSize(horizontal: true, vertical: false)

Listでハマったところ

macOSではSeparatorが引かれない

List での実装を試したときに、なぜか Separator が引かれないという問題が生じました。
「SwiftUI List Separator」というワードでググってもiOSの情報しかなく、ほとんどが「Separatorを消すにはこうするとよいです!」という内容のものでした。
違うんだ、私はSeparatorを引きたいんだ…。

List 内に Text を描画するだけのシンプルなアプリを実装して試したのですが、iOSは自動でSeparatorが引かれるのに対して、macOSではやはりSeparatorは引かれませんでした。

試したコードは以下のような内容です。

struct ContentView: View {
    var body: some View {
        List(0 ..< 5) { index in
            Text("number \(index)")
        }
    }
}

OS間の差異なんですかねぇ。

この問題は、自前で Divider を引くことでSeparatorを引くことができました。

余白部分がタップできない

Button の項でも同じ問題にハマっていましたが、 List でもハマってしまいました。
ListScrollView に書き換えると問題なく動作するので、別の問題が起きていそうです。

これに関しては ScrollView に書き換えれば動くことがわかっているので、諦めました。

参考

SwiftUIの実装を始める前に佐藤さんの同人誌を読んで勉強して、実装中も何度も読み直しました。
実際に機能のあるアプリケーションを実装するパートもあって、読み応えがあります。
私は同人誌版を購入したのですが、現在は商業出版されているので、そちらのリンクを紹介します。

www.amazon.co.jp

まとめ

初めて自分でSwiftUIを使って実装したのですが、面白いですね。
Storyboardで実装しているときの触っただけで差分発生するイライラから解放されるって素晴らしい。
Previewも最高ですね。
AppKitとUIKitの違いに振り回されることも少なそうなので、今後macOSアプリを書くことがあればSwiftUIを採用すると思います!

Illustratorファイル(ai, eps)の作成アプリバージョンと保存バージョンを抽出するコードをSwiftで書いてみた

概要

敬愛するものかのさんが、Illustratorファイル(ai, eps)の作成アプリバージョンと保存バージョンを抽出するコードをPythonで書いたコードを公開されたので、同じようなものをSwiftで書いてみたいと思って書いてみました。
Tweetにあるとおり当初はCLIを書こうかと思ったんですが、ぐちゃっとしたコードを書き直しながらCLI化していると飽きてしまいそうなので、XcodeのPlaygroundにコードを貼り付けたら動くという状態で公開することにしました。

ソースコード

Extract the creation application version and saved version of an Illustrator file (ai, eps) ref: https://gist.github.com/monokano/8bffac0c07401627c5a1ebf020b93b0e

やっていること

  1. ファイルを1MBバイトずつ読み込んで、Data型に変換する
  2. バージョンが表記してある箇所の前後にある文字列をキーとして、バージョンが表記されているRangeを取得する
    • 作成バージョンは AI8_CreatorVersion:%%For の間にある
    • 保存バージョンは %Creator: Adobe Illustrator(R)%%AI8_CreatorVersion: の間にある
  3. 取得したRangeを使って1のDataから値を抽出してString型に変換する
  4. 作成バージョンと保存バージョンが抽出できたらクロージャを実行して、バージョンを出力する

やっていないこと

ものかのさんのPythonスクリプトではCCのバージョンへの読み替えを行っていますが、私のコードではやっていません。

まとめ

私、がんばった

SwiftでData(contentOf:)でファイル読み込む処理と、InputStreamでファイルを読み込む処理を書いた(適当な実行時間計測つき)

概要

ずっと書こうと思って放置していたツールを書くために、Swiftでファイルを読み込む処理について調べたので、サンプルコードを残しておく。

サンプルコード

Playgroundで書いたのでうっかりファイルを削除してもいいように、Gistにあげた。

A sample file loading process using swift.

InputStream を使った新しめのサンプルコードがなくて、Appleのドキュメントを読んだり、Stackoverflowのコードを継ぎ接ぎしたりしたので、最適なコードにはなっていない可能性が高い。

また、サンプルコードの最後に実行時間を貼っておいた。 想定していた通り、 Data(contentOf:) で一気にファイルを読み込むより、InputStream で指定バイト数分ずつ読み込むほうが処理時間は短くすみそうだ

参考

https://developer.apple.com/documentation/foundation/data https://developer.apple.com/documentation/foundation/stream https://stackoverflow.com/questions/26360962/receiving-data-from-nsinputstream-in-swift https://stackoverflow.com/questions/6685785/stream-to-get-data-nsinputstream

InDesignのテキスト流し込みスクリプトの設計を見直してみた

概要

久しぶりにInDesignのテキスト(画像も含む)流し込みスクリプトを実装したときに、安易に過去のスクリプトを3行ぐらいコピペしたところでうんざりしたので、実装のしやすさと処理速度を両立できる設計を検討しつつ実装してみました。
いまさら?っていう内容かもしれないけど、そのときは読み流してそっと閉じてください。

環境

  • macOS 10.15.6
  • InDesign CC 2020

テキストデータの整形

Excel上でテキスト整形

今回の流し込みスクリプトはExcelファイルからテキストを生成することが要件でした。
私が流し込みスクリプトを設計するときはタブ区切りテキストを用意することが多いので、Excelはもってこい。
まず、支給されたExcelのシートとは別に流し込み用テキスト生成用のシートを追加して、そのシートに必要なセルを参照しました。
一行が1ページに流し込むテキストに相当します。
その際に、Excelのセル内改行を独自のメタ文字(適当な文字列)に置き換えたり、空白のセルは空文字になるようにしたり、ざっくりテキスト整形します。
実際は流し込みしてからセル内改行に気づいたんですが、数年ぶりに許しまじCRLFっていう気持ちになりました。許すまじ。

そんなこんなで書き出したテキストファイルをスクリプトで読み込みます。
特に工夫もなくすべての行数を一気に読み込んで、改行コードでsplitして配列にします。

ここまではみんな似たようなことをやりますよね。
ここから先が今回工夫したことです。

テキストの配列をオブジェクトに格納

まず、次のようにExcelの列番号を定数として定義します(ExtendScriptの場合、上書きできちゃうけど、気持ちは定数)。

var ID = 0;
var FULLNAME = 1;
var AGE = 2;
... // 他の列も定義する

続いて、次のようにオブジェクト(例ではdataArray)に格納します。
arrayがテキスト一行文、すなわち1ページに流し込むテキストのデータです。

// 流し込み用のデータを格納する配列
var dataArray = [];

// 1行目はヘッダーなので処理しない
for (var i = 0, iLen = array.length; i < iLen; i++) {
    var column = array[i].split('\t');
    var data = {
        ID: column[ID],
        fullName: column[FULLNAME],
        age: column[AGE],
        ...
    }
    // なにか加工する必要があれば、dataオブジェクトにアクセスして値を更新する

    // 新しくKeyValueを追加することも可能
    data.kind = 'Cat';

    // 配列に詰め込む
    dataArray.push(data);
}

流し込むときにこのオブジェクトから該当するテキストデータを取得すればよく、Excel上でセルの列の番号が変わっても、定数の列番号を新しいものに変更すれば対応可能です。

昔は array[0] で取り出していたんですが、さすがにそれはないだろうと思って、今回のやり方に変えました。

テキストデータの整形はこんなところです。

流し込みフォーマットの整形

流し込み対象のInDesignドキュメントにも工夫を施しました。
要件次第では必要のないものもありますが、一例として紹介します。

一点目は、各オブジェクトの名前をdataオブジェクトのKeyと同じ名前にしました。
これは単純に名前が一緒のほうがどのオブジェクトが対象かわかりやすくなるというだけでなく、流し込みロジックを簡略化できるという強力な効果があります。
流し込みロジックについては後述します。

二点目は、グループオブジェクトや線にも名前をつけました。
これは今回の要件として、以下にあげた項目があったからです。

  • フォーマットに配置されたフレームで足りないときは、スクリプトで必要数分フレームを増やす
  • ページによって必要のないフレームは削除する
  • 流し込み後にフレームの座標位置を上寄せにする
  • グループ内のテキストフレームにも流し込みを行う

名前をつけることにより、その名前をキーにベースとなるグループオブジェクトを複製して配置したり、上寄せのときにテキストフレームと一緒にグループオブジェクトや線の移動ができたりします。
グループ内のグループオブジェクトにも名前をつけると、より細かい制御ができ、おすすめです。

余談ですが、オブジェクトの名前つけにもスクリプトを使用しました。
簡単なものなのでコードは省略しますが、レイヤーパレットでトリプルクリックを繰り返す作業から解放されました。

処理対象のオブジェクト化

データの流し込みの工夫について説明する前に、よくある実装方法をおさらいします。

テキストフレームにテキストを流し込む一番簡単な方法として、以下のようなコードがあげられます。
私のブログを含む、初心者向けの記事によくある書き方です。
内容としては「1ページ目にあるテキストフレームに対して、この本文はダミーですという文字列を流し込む」ものです。

var doc = app.activeDocument;
var myPage = doc.pages[0];
for (var i = 0, iLen = myPage.textFrames.length; i < iLen; i++) {
    var textFrame = myPage.textFrames[i];
    textFrame.contens = 'この本文はダミーです';
}

また、以下のように書くと、指定した名前(この場合はsample)のテキストフレームのみ、テキストの流し込みが行われます。

var doc = app.activeDocument;
var myPage = doc.pages[0];
for (var i = 0, iLen = myPage.textFrames.length; i < iLen; i++) {
    var textFrame = myPage.textFrames[i];
    if (textFrame.name == 'sample') {
        textFrame.contens = 'この本文はダミーです';
    }
}

さて、このふたつのコードにはとある問題が潜んでおり、今回私が担当した案件では期待した結果になりません。
実際にテキスト流し込み用のスクリプトを開発した経験がある方にはすぐ答えがわかってしまう問題ですが、このコードではグループ化されたオブジェクト内のテキストフレームには流し込みが行われません。

正しく処理を行うためには以下のように書き換える必要があります。

var doc = app.activeDocument;
var myPage = doc.pages[0];
for (var i = 0, iLen = myPage.allPageItems.length; i < iLen; i++) {
    var pageItems = myPage.allPageItems[i];
    if (pageItem.constructor.name == 'TextFrame' && pageItem.name == 'sample') {
        textFrame.contens = 'この本文はダミーです';
    }
}

すべてのtextframesを取得する myPage.textFrames ではなく、すべてのpageItemsを取得する myPage.allPageItems に対してfor文を回し、取得したpageItemsに対して Textframe かつ 名前がsampleか というif文で合致するテキストフレームをフィルタリングして、流し込みを行います。

当然流し込みを行うテキストフレームはひとつだけではないですし、テキストフレーム以外のオブジェクトも多数あります。
また、流し込みではありませんが、座標調整のためにグループオブジェクトも取得する必要があります。
そのたびに myPage.allPageItems にアクセスしていたのでは、処理時間が膨大にかかってしまいます。

そこで上述した問題を解決するために、処理対象のオブジェクトをオブジェクトに格納する方法を試しました。

実装はとても簡単で、以下のようなコードを各ページの繰り返し処理の先頭に記述するだけです。

var allTextFramesInSpread = {};
var allGroupsInSpread = {};
var allGraphicLinesInSpread = {};
var allRectanglesInSpread = {};
for (var i = 0, iLen = myPage.allPageItems.length; i < iLen; i++) {
    var pageItem = myPage.allPageItems[i];
    if (pageItem.constructor.name == 'TextFrame' && pageItem.name != "") {
        // 名前のついたテキストフレームをオブジェクトに格納する
        allTextFramesInSpread[pageItem.name] = pageItem;
    } else if (pageItem.constructor.name == 'Group' && pageItem.name != "") {
        // 名前のついたグループをオブジェクトに格納する
        allGroupsInSpread[pageItem.name] = pageItem;
    } else if (pageItem.constructor.name == 'GraphicLine' && pageItem.name != "") {
        // 名前のついた線をオブジェクトに格納する
        allGraphicLinesInSpread[pageItem.name] = pageItem;
    } else if (pageItem.constructor.name == 'Rectangle' && pageItem.name != "") {
        // 名前のついた長方形をオブジェクトに格納する
        allRectanglesInSpread[pageItem.name] = pageItem;
    }
}

意図しない結果の例では個別のフレーム名を指定してオブジェクトを特定しましたが、実際に案件で使用したコードでは名前のついているオブジェクト名だけをオブジェクトに格納しました。

さて、オブジェクト化することの利点とはなんでしょうか。
一度だけ myPage.allPageItems にアクセスしてオブジェクト化してしまえば、ページ全体のオブジェクトの構造が変わらないという条件はありますが、以降は処理対象へのアクセスを作成したオブジェクト経由で行えます。
さらに、名前がついていないオブジェクトをオブジェクト化しないことで、処理を行わないオブジェクトは無視されます。
どちらも処理速度の向上に貢献します。

テキスト流し込み処理の簡略化

ここまで説明してきた「テキストの配列をオブジェクトに格納」「流し込みフォーマットの整形」「処理対象のオブジェクト化」の3つの工夫により、テキスト流し込み処理を簡略化できました。

まず、テキストを流し込めばよいだけのテキストフレームの名前を配列に格納します。

var normalFlowTextFrameNames = [
    "fullName", 
    "age", 
];

続いて、テキストフレームを格納したオブジェクトから、名前をキーに対象を抽出するメソッドを定義します。

function getTextFrameInPage(frameName) {
    return allTextFramesInPage[frameName];
}

最後に、流し込みを行います。

// dataのキーと同じ名前のテキストフレームに値を流し込む
for (var i = 0, iLen = normalFlowTextFrameNames.length; i < iLen; i++) {
    var targetName = normalFlowTextFrameNames[i];
    getTextFrameInPage(targetName).contents = data[targetName];
}

こうすることで、テキストフレームごとに流し込むテキストを指定することなく、一気に流し込みを行えます。
設計・実装してみれば単純な流れではありますが、実装の意識を上寄せ処理やフレーム増減処理に集中させることができて、なかなかよくできた仕組みだと思っています。

注意点

最後に注意点です。
「処理対象のオブジェクト化」のところでも触れましたが、ページの構造が変わると意図しない動作になります。
特に注意すべきはグループオブジェクトで、グループオブジェクトをグループ解除すると、インスタンスの参照が切れてしまい、そのグループ内に含まれていたオブジェクトが取得できなくなります。

具体的には以下のコードのように、isValidでfalseになります。

getGroupInSpread('sample').ungroup();
for (var i = 0, iLen = getGroupInSpread('sample').allPageItems.length; i < iLen; i++) {
    var pageItem = getGroupInSpread('sample').allPageItems[0];
    if (pageItem.isValid) {
        // ここにはこない
    } else {
        // こっちにくる
    }
}

この問題の対策は案外面倒くさいです。
今回は名前をキーにしてグループオブジェクトを取得するメソッドを定義して、その中で対象のオブジェクトに対してisValidで判定してfalseが返ってきたら、ページの第一階層のグループオブジェクトの中から該当する名前のオブジェクトを返すようにしました。
構造を理解しているから使える手段ですが、自分でフォーマットを作る利点でもありますね。

最後に

あまりテキスト流し込みに関する実践的な内容を紹介した記事がなかったので、実際の案件ベースに現時点の私なりの最適解を紹介しました。
これを機に、より有用な記事が公開され、私のスクリプト開発がはかどるようになるといいなぁ。

ダイアログで指定した文字サイズ、文字数、行数をもとに、Illustratorのエリアテキストのサイズを変更するJavaScript「change-textarea-size-for-illustrator」の販売を開始しました

概要

お世話になっている知人から「Illustratorのエリアテキストのサイズを、ダイアログで指定した文字数や行数に応じたサイズに変更するJavaScriptがほしい」と相談を受けて作ったスクリプトです。
Twitterにて実装中のスクリプトの簡単なデモ動画を公開したところ、反響があったので、Boothで販売することにしました。

商品ページ

macneko.booth.pm

Youtubeに紹介動画をアップロードしました(内容はv1.0.0のものです)

change-textarea-size-for-illustratorとは

Illustratorドキュメント上で選択しているエリアテキストのサイズを、ダイアログで指定した文字サイズ、文字数、行数をもとに変更するスクリプトです。
エリアテキストの複数選択や縦組みエリアテキスト、エリアテキスト内に挿入点がある状態、エリアテキスト内の文字を選択した状態でのサイズ変更にも対応しています。
また、ポイントテキストを選択していた場合は該当するテキストの処理をスキップします。
スクリプト起動時に表示するダイアログに表示する初期設定の値は、スクリプトに同梱している「change-textarea-size-for-illustrator_config.txt」(設定ファイル)を書き換えることで、お好みの値に変更できます。

おすすめポイント

  • エリアテキストを複数選択して一気にサイズ変更ができます
  • 自分でサイズを計算しなくても、ダイアログに入力したサイズや文字数などに応じてサイズを変更できます
    • さらに「先頭の文字のサイズにする」を選択することで、文字サイズ・行送りを指定せずにいい感じにサイズを変更できます
  • 設定ファイルの値を書き換えることで、Illustratorの再起動をしなくてもダイアログに表示する初期値を変更できます
  • 複数の単位に対応しており、プルダウンで基準にしたい単位を選択するだけで単位換算ができます

仕様

ダイアログ

スクリプトを起動時に表示するダイアログの各項目の説明は、次の画像を参照ください。

dialog-description

設定ファイル

ダイアログに表示する初期設定の値を変更する場合は、同梱している「change-textarea-size-for-illustrator_config.txt」をテキストエディタで開き、タブ区切りの右側(赤枠で囲んだ部分)の値を書き換えて保存し、スクリプトを再度起動してください。

config-description

動作環境

作者の開発環境を記載します。 - macOS 10.15.5 - Adobe Illustrator 2020 - Adobe Illustrator CC 2019

他のバージョンのIllustratorやWindowsは未検証のため、自己責任でお願いします。

インストール方法

以下のフォルダ内に、「change-textarea-size-for-illustrator」という名前のフォルダを作成して、その中に「change-textarea-size-for-illustrator_(バージョン名).jsx」「change-textarea-size-for-illustrator_config.txt」を移動、またはコピーしてください。

  • Adobe Illustrator 2020
    • /Applications/Adobe Illustrator 2020/Presets.localized/ja_JP/スクリプト
  • Adobe Illustrator CC 2019
    • /Applications/Adobe Illustrator CC 2019/Presets.localized/ja_JP/スクリプト

v1.1.0で追加・改修した機能

2つの機能を追加・改修しました。
どちらもユーザーさんからの要望を取り入れたもので、さらに便利になったと思います。

ダイアログに入力した文字サイズと行送りをエリアテキストに適用する

「入力したサイズから計算する」のラジオボタンを選択し、かつ「文字サイズと行送りを変更する」のチェックボックスにチェックをいれると、エリアテキストの文字数と行送りが、入力された値に変更される機能です。
「先頭の文字サイズから計算する」のラジオボタンを選択すると、「文字サイズと行送りを変更する」のチェックボックスのチェックは自動的に外れます。
また、設定ファイルに「文字サイズと行送りを変更する」の設定値を追加していますので、状況に応じて初期値を変更してお使いいただけます。

ダイアログの行数に0を入力すると行方向のサイズ変更を行わない

見出しのままですが、ダイアログの行数に0を入力したら行方向のサイズ変更を行いません。 エリア内文字オプションの自動サイズ調整をオンにしているときなど、Illustratorにいい感じに調整させたいときにご利用ください。